Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
dfb06ed
feat(app-shared): share Home widget status, freshness, and presentati…
iscekic Oct 9, 2026
1d5044f
feat(web): serve Home widget data with a dedicated read-only credenti…
iscekic Oct 9, 2026
492c18e
feat(mobile): keep Home widgets current and redesign every widget size
iscekic Oct 9, 2026
1d73214
chore(kilo-mcp): refresh catalog.json and SKILL.md from CI
github-actions[bot] Oct 9, 2026
4df96dd
fix(web): forbid org-scoped authority from minting another scope's wi…
iscekic Oct 9, 2026
9987571
fix(kilo-mcp): keep the widget credential issuer out of the MCP catalog
iscekic Oct 9, 2026
2fe9d8d
fix(app-shared): refresh waiting, stale, and expired widgets on the a…
iscekic Oct 9, 2026
e28086a
fix(web): stop widget reads and token writes after the route deadline
iscekic Oct 9, 2026
47acab7
fix(mobile): use the shared Home empty copy in the iOS widget
iscekic Oct 9, 2026
673cfde
fix(mobile): rank approval candidates like the server's displayed req…
iscekic Oct 9, 2026
a533929
fix(mobile): degrade instead of crashing when the widget app group is…
iscekic Oct 9, 2026
6c18d66
test(notifications): type the widget-hint fake read
iscekic Oct 9, 2026
87696b2
fix(mobile): send the widget configuration as JSON so null fields sur…
iscekic Oct 9, 2026
f8bcd2e
fix(mobile): fill every widget size and keep both platforms in step
iscekic Oct 9, 2026
6e51e64
fix(mobile): keep the fixture approval key hex and stop the card over…
iscekic Oct 9, 2026
19773e2
test(mobile): type the card boundary sizes as tuples
iscekic Oct 9, 2026
5d741c5
fix(mobile): keep a widget press alive until the app consumes it
iscekic Oct 9, 2026
dc6d969
fix(mobile): keep the server-minted approval key through a tray derive
iscekic Oct 9, 2026
a8b9a78
test(mobile): pin the approval key identity rule
iscekic Oct 9, 2026
a883a54
feat(notifications): carry the check time in the Live Activity conten…
iscekic Oct 9, 2026
10f9182
feat(mobile): rebuild iOS widgets and Live Activity to the approved d…
iscekic Oct 9, 2026
59aeb59
feat(mobile): rebuild Android widgets and ongoing card to the approve…
iscekic Oct 9, 2026
73e3e13
chore(mobile): drop the unused scheduled-wakes copy and dead exports
iscekic Oct 9, 2026
5c210f2
Merge remote-tracking branch 'origin/main' into igor/widget-refresh-d…
iscekic Oct 9, 2026
5c1a21c
test(mobile): exercise a real session change behind the same visible ask
iscekic Oct 9, 2026
4d61d36
chore(tools): let widget-process layouts format dates in-process
iscekic Oct 9, 2026
f1cb484
test(mobile): expect the check time in the cold Live Activity state
iscekic Oct 9, 2026
8a6a863
fix(mobile): show Approving… on an Android approve retry
iscekic Oct 10, 2026
1de7a77
test(mobile): republish after the session change behind the same ask
iscekic Oct 10, 2026
ab5f8d9
test(notifications): drop the duplicate updatedAt content-state test
iscekic Oct 10, 2026
856cbf8
fix(mobile): key the scheduled rows in the iOS Large widget
iscekic Oct 10, 2026
55039ce
fix(mobile): clear actionFeedback on the iOS widget background rebuild
iscekic Oct 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .kilo/skills/kilo-mcp/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ to send platform feedback and bug reports rather than a support channel.
The first path segment names an area. Search to learn an area's vocabulary:
each row's `summary` and `tags` are what the search matches.

**863 procedures** — **370 queries**, **493 mutations** — under **53 prefixes**.
**864 procedures** — **371 queries**, **493 mutations** — under **53 prefixes**.

| Prefix | Procedures | Queries | Mutations |
|---|---:|---:|---:|
Expand Down Expand Up @@ -88,7 +88,7 @@ each row's `summary` and `tags` are what the search matches.
| `modelPreferences` | 6 | 1 | 5 |
| `usageAnalytics` | 6 | 6 | 0 |
| `workspaceFolders` | 6 | 1 | 5 |
| `activeSessions` | 4 | 3 | 1 |
| `activeSessions` | 5 | 4 | 1 |
| `debug` | 4 | 4 | 0 |
| `discord` | 4 | 1 | 3 |
| `securityAuditLog` | 4 | 3 | 1 |
Expand Down
2 changes: 2 additions & 0 deletions apps/mobile/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -484,6 +484,8 @@ const config: ExpoConfig = {
// Aggregate "Active Agents" glanceable surfaces: one Live Activity plus Home
// Screen and Lock Screen widgets, rendered by src/glanceable-ios. The widget
// target reuses the existing app group; no second group is created.
// Native Home refresh runs after expo-widgets' source generation (mods run in reverse).
'./plugins/withHomeWidgetRefresh',
[
'expo-widgets',
{
Expand Down
Binary file removed apps/mobile/assets/images/logo-widget.png
Binary file not shown.
26 changes: 8 additions & 18 deletions apps/mobile/index.js
Original file line number Diff line number Diff line change
@@ -1,18 +1,8 @@
// The app entry.
//
// Android redraws a placed widget, and answers the ongoing notification's
// Approve action, from a headless JS task: the bundle loads with no Activity,
// no route and no notification handler runs first, so both tasks have to be
// registered here. The widget module loads only when its task fires — requiring
// it at entry would start the widget sink before `expo-router/entry` sets the
// app up.
// Android widget redraws, native refresh, and notification approval run without
// an Activity. Register their lazy task factories before the router entry.
//
// The Approve action on the Live Update notification boots the same kind of
// headless run, so its task is registered here too, under the key its Kotlin
// worker starts (`KiloActiveAgentsApprove`). `registerApproveTask` is the second
// registration that action can take, through `ActiveAgentsApproveTaskService`:
// it requires the task module itself, which is why the literal above is only a
// factory.
//
// `require`, not `import`: ESM hoisting would run `expo-router/entry` first.
const { AppRegistry, LogBox, Platform } = require('react-native');
Expand All @@ -32,12 +22,12 @@ if (Platform.OS === 'android') {
await handleWidgetTask(task);
});

// `KiloActiveAgentsApprove` is `APPROVE_HEADLESS_TASK_KEY`
// (src/glanceable-android/approve-task.ts) and the Kotlin worker's
// `TASK_NAME`; only the string crosses the native boundary, so the three are
// asserted equal in approve-task.test.ts. The factory keeps this registration
// bodyless, like the widget handler above; the module itself arrives with the
// eager `registerApproveTask` require.
AppRegistry.registerHeadlessTask(
'HomeWidgetRefresh',
() => require('./src/glanceable-android/register').handleHomeWidgetRefresh
);

// The Kotlin approval worker starts this key without a foreground Activity.
AppRegistry.registerHeadlessTask(
'KiloActiveAgentsApprove',
() => require('./src/glanceable-android/approve-task').handleApproveTask
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,20 @@ import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.res.Configuration
import android.graphics.Color
import android.graphics.drawable.Icon
import android.net.Uri
import android.os.Build
import android.text.SpannableString
import android.text.Spanned
import android.text.style.ForegroundColorSpan
import android.util.Log
import expo.modules.kotlin.exception.Exceptions
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
import expo.modules.kotlin.records.Field
import expo.modules.kotlin.records.Record
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
import kotlinx.coroutines.CoroutineScope
Expand All @@ -24,9 +31,10 @@ import kotlinx.coroutines.asCoroutineDispatcher
* its creation), the alert decision, and the revision guard; this module owns
* the fixed notification id, the posted-channel mirror, the API 36.1+ promotion
* gate, and the content intent plus named actions: Open deep-links into the
* recorded session (the Agents tab when nothing waits), and Approve runs the
* headless approval when a cloud-agent permission waits. Both platforms answer
* through `src/lib/glanceable/approve-ask.ts`.
* recorded session (the Agents tab when nothing waits), Approve runs the
* headless approval when a cloud-agent permission waits, and New agent opens
* the composer on a working card. Both platforms answer through
* `src/lib/glanceable/approve-ask.ts`.
*
* This is the Android mechanism for the one shared kind model, not a second
* behaviour: `@kilocode/notifications` maps each agent surface to `needs-input`
Expand Down Expand Up @@ -58,8 +66,8 @@ class ActiveAgentsLiveUpdateModule : Module() {
notificationManager.isNotificationPolicyAccessGranted
}

// Group the Open label and URL so both entry points fit Expo's eight-argument
// Function limit while preserving the action and notification-kind fields.
// The card's content and actions travel as one record so both entry points
// fit Expo's eight-argument Function limit beside the notification-kind fields.
//
// `start`, `update` and `end` are the durable writes whose state the JS side
// never reads back in the same turn: their bodies commit to SharedPreferences
Expand All @@ -68,20 +76,18 @@ class ActiveAgentsLiveUpdateModule : Module() {
// thread. The JS bridge (`src/glanceable-android/live-update.ts`) declares
// them `void` and never consumes the promise, which is why `durable` logs a
// failure instead of letting it become an unhandled rejection.
AsyncFunction("start") { title: String, text: String, openAction: Map<String, String>, approveLabel: String?, compactText: String?, channelId: String, alerting: Boolean, promotion: Boolean ->
AsyncFunction("start") { card: LiveUpdateCard, channelId: String, alerting: Boolean, promotion: Boolean ->
durable {
post(title, text, openAction.getValue("label"), openAction.getValue("url"), approveLabel, compactText, channelId, alerting, promotion, 0)
post(card, channelId, alerting, promotion, 0)
}
}.runOnQueue(moduleQueue)

// Expo's `Function` builder has one overload per arity and stops at eight
// arguments (expo-modules-core `ObjectDefinitionBuilder`), so `update`
// cannot carry `start`'s `promotion` flag on top of the terminal
// `timeoutMs`. The flag is redundant on this path: `post` gates promotion
// on `isPromotionCapable()` itself, which is the value the JS side passed.
AsyncFunction("update") { title: String, text: String, openAction: Map<String, String>, approveLabel: String?, compactText: String?, channelId: String, alerting: Boolean, timeoutMs: Double ->
// `update` carries the terminal `timeoutMs` instead of `start`'s `promotion`
// flag. The flag is redundant on this path: `post` gates promotion on
// `isPromotionCapable()` itself, which is the value the JS side passes.
AsyncFunction("update") { card: LiveUpdateCard, channelId: String, alerting: Boolean, timeoutMs: Double ->
durable {
post(title, text, openAction.getValue("label"), openAction.getValue("url"), approveLabel, compactText, channelId, alerting, isPromotionCapable(), timeoutMs.toLong())
post(card, channelId, alerting, isPromotionCapable(), timeoutMs.toLong())
}
}.runOnQueue(moduleQueue)

Expand Down Expand Up @@ -279,6 +285,30 @@ class ActiveAgentsLiveUpdateModule : Module() {
)
}

/**
* The New agent action: the app's fixed new-agent deep link. One constant URL,
* so the record under its request code never accumulates.
*/
private fun newAgentPendingIntent(url: String): PendingIntent {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)).apply {
setPackage(context.packageName)
}
return PendingIntent.getActivity(
context,
NEW_AGENT_REQUEST_CODE,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
}

/** The app is drawing in its dark theme: the card's accent and retry red follow it. */
private fun isNight(): Boolean =
(context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) ==
Configuration.UI_MODE_NIGHT_YES

private fun action(label: String, intent: PendingIntent): Notification.Action =
Notification.Action.Builder(Icon.createWithResource(context, smallIconId()), label, intent).build()

/**
* The channel a card still in the shade carries, or null when no card is
* posted. The stored marker alone can outlive the card: a terminal timeout
Expand All @@ -302,17 +332,13 @@ class ActiveAgentsLiveUpdateModule : Module() {
}

private fun post(
title: String,
text: String,
openLabel: String,
openUrl: String,
approveLabel: String?,
compactText: String?,
card: LiveUpdateCard,
channelId: String,
alerting: Boolean,
promotion: Boolean,
timeoutMs: Long
) {
val openUrl = card.openUrl
// Read the URL the shade card carries before this call can change it: a
// failed post must leave `OPEN_URL` naming the previous card's record.
val previousOpenUrl = notificationState.getString(OPEN_URL, null)
Expand All @@ -323,32 +349,38 @@ class ActiveAgentsLiveUpdateModule : Module() {
// uses. A needs-input card takes both; a progress card keeps the status
// category and the silent behaviour it had.
val needsInput = channelId == NEEDS_INPUT_CHANNEL_ID
// The system template, coloured with the app's primary (olive by day, lime
// by night). The retry line after a failed approve draws in the destructive
// red; everything else is the template's own text colour.
val night = isNight()
val text: CharSequence = if (card.textIsError) {
SpannableString(card.text).apply {
setSpan(
ForegroundColorSpan(Color.parseColor(if (night) ERROR_NIGHT else ERROR_DAY)),
0,
length,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
)
}
} else {
card.text
}
val builder = newBuilder(channelId)
.setSmallIcon(smallIconId())
.setContentTitle(title)
.setColor(Color.parseColor(if (night) PRIMARY_NIGHT else PRIMARY_DAY))
.setContentTitle(card.title)
.setContentText(text)
.setSubText(card.subText)
.setContentIntent(contentIntent)
.setOngoing(true)
.setCategory(if (needsInput) Notification.CATEGORY_MESSAGE else Notification.CATEGORY_STATUS)
.addAction(
Notification.Action.Builder(
Icon.createWithResource(context, smallIconId()),
openLabel,
contentIntent
).build()
)

// No recorded approvable ask: the action is omitted, not disabled. The JS
// side drops the label once the wait is answered elsewhere.
if (approveLabel != null) {
builder.addAction(
Notification.Action.Builder(
Icon.createWithResource(context, smallIconId()),
approveLabel,
approvePendingIntent()
).build()
)
}

// Approve first, then Open, then New agent. An action the card does not
// offer is omitted, not disabled: the JS side drops Approve once the wait is
// answered elsewhere or while an answer is in flight.
card.approveLabel?.let { builder.addAction(action(it, approvePendingIntent())) }
builder.addAction(action(card.openLabel, contentIntent))
card.newAgentLabel?.let { builder.addAction(action(it, newAgentPendingIntent(card.newAgentUrl))) }

if (needsInput) {
// Only the first entry into the kind alerts; a later update in the same
Expand All @@ -363,7 +395,7 @@ class ActiveAgentsLiveUpdateModule : Module() {
// setRequestPromotedOngoing does not exist; use the documented flag setter.
if (promotion && isPromotionCapable()) {
builder.setFlag(Notification.FLAG_PROMOTED_ONGOING, true)
builder.setShortCriticalText(compactText)
builder.setShortCriticalText(card.compactText)
builder.setStyle(Notification.ProgressStyle())
}

Expand Down Expand Up @@ -462,6 +494,13 @@ class ActiveAgentsLiveUpdateModule : Module() {
/** The kind marker in the channel id the JS side creates for needs-input. */
const val NEEDS_INPUT_CHANNEL_ID = "needs-input"
const val APPROVE_REQUEST_CODE = 1003
const val NEW_AGENT_REQUEST_CODE = 1004

/** The app's primary and destructive colours (`global.css`), day and night. */
const val PRIMARY_DAY = "#4F5A10"
const val PRIMARY_NIGHT = "#E8F27A"
const val ERROR_DAY = "#B0483A"
const val ERROR_NIGHT = "#F28B7A"

/**
* The module's one serial queue for the durable write path, shared by every
Expand Down Expand Up @@ -491,3 +530,17 @@ class ActiveAgentsLiveUpdateModule : Module() {
Executors.newSingleThreadExecutor { Thread(it, "active-agents-live-update") }
}
}

/** One card's content and actions, as the JS bridge (`live-update.ts`) sends it. */
class LiveUpdateCard : Record {
@Field val title: String = ""
@Field val text: String = ""
@Field val textIsError: Boolean = false
@Field val subText: String? = null
@Field val compactText: String? = null
@Field val openLabel: String = ""
@Field val openUrl: String = ""
@Field val approveLabel: String? = null
@Field val newAgentLabel: String? = null
@Field val newAgentUrl: String = ""
}
14 changes: 14 additions & 0 deletions apps/mobile/modules/home-widget-refresh/android/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
apply plugin: 'com.android.library'
group = 'com.kilocode.homewidgetrefresh'
version = '1.0.0'
def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
apply from: expoModulesCorePlugin
applyKotlinExpoModulesCorePlugin()
useCoreDependencies()
useExpoPublishing()
useDefaultAndroidSdkVersions()
android { namespace 'com.kilocode.homewidgetrefresh' }
dependencies {
implementation 'androidx.work:work-runtime:2.8.1'
implementation 'com.facebook.react:react-android'
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
</manifest>
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package com.kilocode.homewidgetrefresh

import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
import org.json.JSONObject
import org.json.JSONArray

class HomeWidgetRefreshModule : Module() {
private val operationLock = Any()
private var operationEpoch = 0L
private var destroyed = false
private val context get() = requireNotNull(appContext.reactContext)
override fun definition() = ModuleDefinition {
Name("HomeWidgetRefresh")
Function("getOperationEpoch") { synchronized(operationLock) { operationEpoch.toDouble() } }
AsyncFunction("configure") { payload: String ->
val json = JSONObject(payload)
val epoch = json.getLong("operationEpoch")
synchronized(operationLock) {
if (!destroyed && epoch > operationEpoch) {
operationEpoch = epoch
HomeWidgetStore.configure(context, json)
}
}
}
AsyncFunction("clear") { epoch: Long ->
synchronized(operationLock) {
if (!destroyed && epoch > operationEpoch) {
operationEpoch = epoch
HomeWidgetStore.clear(context)
}
}
}
AsyncFunction("setFixtureMode") { enabled: Boolean -> HomeWidgetStore.fixture(context, enabled) }
AsyncFunction("getData") { HomeWidgetStore.data(context)?.let { jsonMap(it) } }
AsyncFunction("getWidgetPushToken") { null as String? }
AsyncFunction("widgetsChanged") { HomeWidgetStore.schedule(context, replace = true) }
AsyncFunction("isCurrent") { scope: String, epoch: Int, generation: String -> HomeWidgetStore.current(context, scope, epoch, generation) }
OnDestroy {
synchronized(operationLock) {
destroyed = true
operationEpoch += 1
}
}
}
}

internal fun jsonValue(value: Any?): Any? = when (value) {
JSONObject.NULL -> null
is JSONObject -> jsonMap(value)
is JSONArray -> (0 until value.length()).map { jsonValue(value.get(it)) }
else -> value
}
internal fun jsonMap(value: JSONObject): Map<String, Any?> = value.keys().asSequence().associateWith { jsonValue(value.get(it)) }
Loading
Loading