@raidiant/notifai 5.0.0 → 7.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +51 -0
- package/README.md +2 -2
- package/dist/atomic-file.js +13 -0
- package/dist/atomic-file.js.map +1 -1
- package/dist/client.d.ts +8 -3
- package/dist/client.js +24 -3
- package/dist/client.js.map +1 -1
- package/dist/commands-auth.d.ts +14 -0
- package/dist/commands-auth.js +154 -0
- package/dist/commands-auth.js.map +1 -0
- package/dist/commands-config.d.ts +31 -0
- package/dist/commands-config.js +246 -0
- package/dist/commands-config.js.map +1 -0
- package/dist/commands-core.d.ts +102 -0
- package/dist/commands-core.js +135 -0
- package/dist/commands-core.js.map +1 -0
- package/dist/commands-devices.d.ts +31 -0
- package/dist/commands-devices.js +124 -0
- package/dist/commands-devices.js.map +1 -0
- package/dist/commands-doctor.d.ts +24 -0
- package/dist/commands-doctor.js +1011 -0
- package/dist/commands-doctor.js.map +1 -0
- package/dist/commands-harness-context.d.ts +65 -0
- package/dist/commands-harness-context.js +97 -0
- package/dist/commands-harness-context.js.map +1 -0
- package/dist/commands-hook-shape.d.ts +17 -0
- package/dist/commands-hook-shape.js +37 -0
- package/dist/commands-hook-shape.js.map +1 -0
- package/dist/commands-hooks.d.ts +115 -0
- package/dist/commands-hooks.js +1225 -0
- package/dist/commands-hooks.js.map +1 -0
- package/dist/commands-init.d.ts +35 -0
- package/dist/commands-init.js +580 -0
- package/dist/commands-init.js.map +1 -0
- package/dist/commands-io.d.ts +2 -0
- package/dist/commands-io.js +110 -0
- package/dist/commands-io.js.map +1 -0
- package/dist/commands-logs.d.ts +30 -0
- package/dist/commands-logs.js +201 -0
- package/dist/commands-logs.js.map +1 -0
- package/dist/commands-send-support.d.ts +44 -0
- package/dist/commands-send-support.js +183 -0
- package/dist/commands-send-support.js.map +1 -0
- package/dist/commands-send.d.ts +33 -0
- package/dist/commands-send.js +519 -0
- package/dist/commands-send.js.map +1 -0
- package/dist/commands-setup-proof.d.ts +15 -0
- package/dist/commands-setup-proof.js +53 -0
- package/dist/commands-setup-proof.js.map +1 -0
- package/dist/commands-skill.d.ts +10 -0
- package/dist/commands-skill.js +71 -0
- package/dist/commands-skill.js.map +1 -0
- package/dist/commands.d.ts +12 -387
- package/dist/commands.js +12 -4536
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.js +1 -1
- package/dist/config-schema.js.map +1 -1
- package/dist/config.d.ts +4 -4
- package/dist/config.js +6 -4
- package/dist/config.js.map +1 -1
- package/dist/credentials.js +13 -3
- package/dist/credentials.js.map +1 -1
- package/dist/generated-session-label.d.ts +2 -0
- package/dist/generated-session-label.js +152 -0
- package/dist/generated-session-label.js.map +1 -0
- package/dist/hooks.d.ts +2 -2
- package/dist/hooks.js +127 -99
- package/dist/hooks.js.map +1 -1
- package/dist/interactive.js +4 -4
- package/dist/interactive.js.map +1 -1
- package/dist/invocation-context.d.ts +3 -0
- package/dist/invocation-context.js +23 -6
- package/dist/invocation-context.js.map +1 -1
- package/dist/logging.js +1 -0
- package/dist/logging.js.map +1 -1
- package/dist/main.js +3 -3
- package/dist/main.js.map +1 -1
- package/dist/opencode-plugin.js +18 -3
- package/dist/opencode-plugin.js.map +1 -1
- package/dist/readiness.d.ts +3 -1
- package/dist/readiness.js.map +1 -1
- package/dist/session-labels.d.ts +30 -0
- package/dist/session-labels.js +278 -0
- package/dist/session-labels.js.map +1 -0
- package/package.json +2 -2
- package/src/atomic-file.ts +12 -0
- package/src/client.test.ts +90 -1
- package/src/client.ts +35 -2
- package/src/commands-auth.ts +174 -0
- package/src/commands-config.ts +307 -0
- package/src/commands-core.ts +240 -0
- package/src/commands-devices.ts +150 -0
- package/src/commands-doctor.ts +1209 -0
- package/src/commands-harness-context.ts +134 -0
- package/src/commands-hook-shape.ts +53 -0
- package/src/commands-hooks.ts +1586 -0
- package/src/commands-init.ts +721 -0
- package/src/commands-io.ts +108 -0
- package/src/commands-logs.ts +243 -0
- package/src/commands-send-support.ts +227 -0
- package/src/commands-send.ts +623 -0
- package/src/commands-setup-proof.ts +71 -0
- package/src/commands-skill.ts +84 -0
- package/src/commands.test.ts +567 -42
- package/src/commands.ts +58 -5614
- package/src/config-schema.ts +1 -1
- package/src/config.test.ts +9 -0
- package/src/config.ts +10 -5
- package/src/credentials.test.ts +25 -1
- package/src/credentials.ts +13 -3
- package/src/generated-session-label.ts +155 -0
- package/src/hooks.test.ts +355 -16
- package/src/hooks.ts +145 -97
- package/src/install-hooks.test.ts +84 -1
- package/src/interactive.test.ts +19 -1
- package/src/interactive.ts +4 -4
- package/src/invocation-context.test.ts +115 -16
- package/src/invocation-context.ts +32 -11
- package/src/logging.test.ts +15 -2
- package/src/logging.ts +1 -0
- package/src/main.ts +9 -3
- package/src/opencode-plugin.ts +18 -3
- package/src/readiness.ts +3 -1
- package/src/session-labels.test.ts +303 -0
- package/src/session-labels.ts +360 -0
- package/src/skill.test.ts +13 -0
package/src/hooks.ts
CHANGED
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
import { buildDraft } from './send.js'
|
|
39
39
|
import { atomicWriteFileSync } from './atomic-file.js'
|
|
40
40
|
import { withFileLock } from './file-lock.js'
|
|
41
|
+
import { inferInvocationContext } from './invocation-context.js'
|
|
41
42
|
import type { Logger } from './logging.js'
|
|
42
43
|
import {
|
|
43
44
|
HARNESS_CAPABILITIES,
|
|
@@ -242,6 +243,22 @@ export interface PendingQuestion {
|
|
|
242
243
|
submission?: PendingSubmissionIntent
|
|
243
244
|
}
|
|
244
245
|
|
|
246
|
+
/** Keep immutable session grouping while replacing per-event Git location authoritatively. */
|
|
247
|
+
function sourceContextAtHookEvent(
|
|
248
|
+
source: SourceContextT | undefined,
|
|
249
|
+
cwd: string | undefined,
|
|
250
|
+
): SourceContextT | undefined {
|
|
251
|
+
const invocation = cwd === undefined ? null : inferInvocationContext(cwd)
|
|
252
|
+
const current: SourceContextT = {
|
|
253
|
+
...(source?.session_id !== undefined ? { session_id: source.session_id } : {}),
|
|
254
|
+
...(source?.session_label !== undefined ? { session_label: source.session_label } : {}),
|
|
255
|
+
...(source?.harness !== undefined ? { harness: source.harness } : {}),
|
|
256
|
+
...(invocation?.branch !== undefined ? { branch: invocation.branch } : {}),
|
|
257
|
+
...(invocation?.worktree !== undefined ? { worktree: invocation.worktree } : {}),
|
|
258
|
+
}
|
|
259
|
+
return Object.keys(current).length === 0 ? undefined : current
|
|
260
|
+
}
|
|
261
|
+
|
|
245
262
|
interface PendingSubmissionIntent {
|
|
246
263
|
request_id: string
|
|
247
264
|
idempotency_key: string
|
|
@@ -643,6 +660,50 @@ interface StoredProjectSessionPointer extends ProjectSessionPointer {
|
|
|
643
660
|
updatedAt: number
|
|
644
661
|
}
|
|
645
662
|
|
|
663
|
+
interface StoredProjectSessionDocument {
|
|
664
|
+
root: Record<string, unknown>
|
|
665
|
+
sessions: unknown[]
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
export interface ProjectSessionPointer {
|
|
669
|
+
sessionId: string
|
|
670
|
+
harness: HookHarness
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function storedProjectSessionPointer(candidate: unknown): StoredProjectSessionPointer | null {
|
|
674
|
+
if (typeof candidate !== 'object' || candidate === null || Array.isArray(candidate)) return null
|
|
675
|
+
const entry = candidate as {
|
|
676
|
+
session_id?: unknown
|
|
677
|
+
updated_at?: unknown
|
|
678
|
+
harness?: unknown
|
|
679
|
+
}
|
|
680
|
+
if (typeof entry.session_id !== 'string' || entry.session_id === '') return null
|
|
681
|
+
if (typeof entry.updated_at !== 'number') return null
|
|
682
|
+
if (!(HARNESSES as readonly unknown[]).includes(entry.harness)) return null
|
|
683
|
+
return {
|
|
684
|
+
sessionId: entry.session_id,
|
|
685
|
+
updatedAt: entry.updated_at,
|
|
686
|
+
harness: entry.harness as HookHarness,
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function readStoredProjectSessionDocument(file: string): StoredProjectSessionDocument {
|
|
691
|
+
if (!existsSync(file)) return { root: {}, sessions: [] }
|
|
692
|
+
try {
|
|
693
|
+
const parsed: unknown = JSON.parse(readFileSync(file, 'utf8'))
|
|
694
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
695
|
+
return { root: {}, sessions: [] }
|
|
696
|
+
}
|
|
697
|
+
const root = parsed as Record<string, unknown>
|
|
698
|
+
return {
|
|
699
|
+
root,
|
|
700
|
+
sessions: Array.isArray(root['sessions']) ? root['sessions'] : [],
|
|
701
|
+
}
|
|
702
|
+
} catch {
|
|
703
|
+
return { root: {}, sessions: [] }
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
646
707
|
/** Records every live session working in a directory, for `notifai ask`. */
|
|
647
708
|
export function writeProjectSession(
|
|
648
709
|
cwd: string,
|
|
@@ -653,56 +714,37 @@ export function writeProjectSession(
|
|
|
653
714
|
): void {
|
|
654
715
|
const file = projectSessionPointerPath(cwd, env)
|
|
655
716
|
withFileLock(`${file}.lock`, () => {
|
|
656
|
-
const
|
|
657
|
-
|
|
658
|
-
|
|
717
|
+
const stored = readStoredProjectSessionDocument(file)
|
|
718
|
+
const existing: unknown[] = []
|
|
719
|
+
let refreshed: Record<string, unknown> = {}
|
|
720
|
+
for (const candidate of stored.sessions) {
|
|
721
|
+
const entry = storedProjectSessionPointer(candidate)
|
|
722
|
+
if (entry?.sessionId === sessionId) {
|
|
723
|
+
refreshed = { ...refreshed, ...(candidate as Record<string, unknown>) }
|
|
724
|
+
} else if (entry === null || now - entry.updatedAt <= 24 * 3600 * 1000) {
|
|
725
|
+
existing.push(candidate)
|
|
726
|
+
}
|
|
727
|
+
}
|
|
659
728
|
atomicWriteFileSync(
|
|
660
729
|
file,
|
|
661
730
|
`${JSON.stringify({
|
|
731
|
+
...stored.root,
|
|
662
732
|
sessions: [
|
|
663
|
-
...existing
|
|
664
|
-
|
|
665
|
-
updated_at: entry.updatedAt,
|
|
666
|
-
harness: entry.harness,
|
|
667
|
-
})),
|
|
668
|
-
{ session_id: sessionId, updated_at: now, harness },
|
|
733
|
+
...existing,
|
|
734
|
+
{ ...refreshed, session_id: sessionId, updated_at: now, harness },
|
|
669
735
|
],
|
|
670
736
|
})}\n`,
|
|
671
737
|
)
|
|
672
738
|
})
|
|
673
739
|
}
|
|
674
740
|
|
|
675
|
-
export interface ProjectSessionPointer {
|
|
676
|
-
sessionId: string
|
|
677
|
-
harness: HookHarness
|
|
678
|
-
}
|
|
679
|
-
|
|
680
741
|
function readStoredProjectSessionPointers(file: string): StoredProjectSessionPointer[] {
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
const entry = candidate as {
|
|
688
|
-
session_id?: unknown
|
|
689
|
-
updated_at?: unknown
|
|
690
|
-
harness?: unknown
|
|
691
|
-
}
|
|
692
|
-
if (typeof entry.session_id !== 'string' || entry.session_id === '') return []
|
|
693
|
-
if (typeof entry.updated_at !== 'number') return []
|
|
694
|
-
if (!(HARNESSES as readonly unknown[]).includes(entry.harness)) return []
|
|
695
|
-
return [
|
|
696
|
-
{
|
|
697
|
-
sessionId: entry.session_id,
|
|
698
|
-
updatedAt: entry.updated_at,
|
|
699
|
-
harness: entry.harness as HookHarness,
|
|
700
|
-
},
|
|
701
|
-
]
|
|
702
|
-
})
|
|
703
|
-
} catch {
|
|
704
|
-
return []
|
|
705
|
-
}
|
|
742
|
+
return readStoredProjectSessionDocument(file).sessions.flatMap(
|
|
743
|
+
(candidate): StoredProjectSessionPointer[] => {
|
|
744
|
+
const entry = storedProjectSessionPointer(candidate)
|
|
745
|
+
return entry === null ? [] : [entry]
|
|
746
|
+
},
|
|
747
|
+
)
|
|
706
748
|
}
|
|
707
749
|
|
|
708
750
|
/**
|
|
@@ -792,21 +834,21 @@ function clearMatchingProjectSession(
|
|
|
792
834
|
): void {
|
|
793
835
|
const file = projectSessionPointerPath(cwd, env)
|
|
794
836
|
withFileLock(`${file}.lock`, () => {
|
|
795
|
-
const
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
837
|
+
const stored = readStoredProjectSessionDocument(file)
|
|
838
|
+
const remaining = stored.sessions.filter((candidate) => {
|
|
839
|
+
const entry = storedProjectSessionPointer(candidate)
|
|
840
|
+
return entry === null || entry.sessionId !== sessionId
|
|
841
|
+
})
|
|
842
|
+
const hasUnknownRootFields = Object.keys(stored.root).some((key) => key !== 'sessions')
|
|
843
|
+
if (remaining.length === 0 && !hasUnknownRootFields) {
|
|
799
844
|
rmSync(file, { force: true })
|
|
800
845
|
return
|
|
801
846
|
}
|
|
802
847
|
atomicWriteFileSync(
|
|
803
848
|
file,
|
|
804
849
|
`${JSON.stringify({
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
updated_at: entry.updatedAt,
|
|
808
|
-
harness: entry.harness,
|
|
809
|
-
})),
|
|
850
|
+
...stored.root,
|
|
851
|
+
sessions: remaining,
|
|
810
852
|
})}\n`,
|
|
811
853
|
)
|
|
812
854
|
})
|
|
@@ -1059,7 +1101,8 @@ async function answerableDevices(ctx: HookContext): Promise<string[]> {
|
|
|
1059
1101
|
(device) =>
|
|
1060
1102
|
(device.platform === 'ios' || device.platform === 'macos') &&
|
|
1061
1103
|
device.registration_healthy &&
|
|
1062
|
-
device.
|
|
1104
|
+
device.capabilities?.includes('answer') === true &&
|
|
1105
|
+
device.derived_status !== 'must_update',
|
|
1063
1106
|
)
|
|
1064
1107
|
.filter((device) => configured === null || configured.includes(device.device_id))
|
|
1065
1108
|
.map((device) => device.device_id)
|
|
@@ -1149,7 +1192,6 @@ const RETIREMENT_TITLES: Record<LifecycleEndState, string> = {
|
|
|
1149
1192
|
answered: 'Answered',
|
|
1150
1193
|
answered_elsewhere: 'Answered in the terminal',
|
|
1151
1194
|
expired: 'Question expired',
|
|
1152
|
-
superseded: 'Replaced by a newer question',
|
|
1153
1195
|
}
|
|
1154
1196
|
|
|
1155
1197
|
/**
|
|
@@ -1192,6 +1234,7 @@ export function parkForRetirement(
|
|
|
1192
1234
|
function retiringQuestion(
|
|
1193
1235
|
pending: PendingQuestion,
|
|
1194
1236
|
state: LifecycleEndState,
|
|
1237
|
+
cwd?: string,
|
|
1195
1238
|
): RetiringQuestion | null {
|
|
1196
1239
|
const hasRequest = pending.request_id !== undefined
|
|
1197
1240
|
const hasCollapse = pending.collapse_key !== undefined
|
|
@@ -1202,13 +1245,14 @@ function retiringQuestion(
|
|
|
1202
1245
|
'live question state is incomplete; refusing to retire it without request, collapse, and device identifiers',
|
|
1203
1246
|
)
|
|
1204
1247
|
}
|
|
1248
|
+
const source = sourceContextAtHookEvent(pending.source, cwd)
|
|
1205
1249
|
return {
|
|
1206
1250
|
request_id: pending.request_id!,
|
|
1207
1251
|
collapse_key: pending.collapse_key!,
|
|
1208
1252
|
device_ids: [...pending.device_ids!],
|
|
1209
1253
|
question: pending.question,
|
|
1210
1254
|
...(pending.project !== undefined ? { project: pending.project } : {}),
|
|
1211
|
-
...(
|
|
1255
|
+
...(source !== undefined ? { source } : {}),
|
|
1212
1256
|
state,
|
|
1213
1257
|
}
|
|
1214
1258
|
}
|
|
@@ -1891,9 +1935,10 @@ function settleAcceptedAnswers(
|
|
|
1891
1935
|
ctx: HookContext,
|
|
1892
1936
|
sessionId: string,
|
|
1893
1937
|
accepted: AcceptedAnswerDelivery,
|
|
1938
|
+
cwd?: string,
|
|
1894
1939
|
): void {
|
|
1895
1940
|
const retirements = accepted.answers
|
|
1896
|
-
.map(({ pending }) => retiringQuestion(pending, 'answered'))
|
|
1941
|
+
.map(({ pending }) => retiringQuestion(pending, 'answered', cwd))
|
|
1897
1942
|
.filter((entry): entry is RetiringQuestion => entry !== null)
|
|
1898
1943
|
updateSessionState(sessionId, ctx.env, (current) => {
|
|
1899
1944
|
const retiring = [...(current.retiring ?? [])]
|
|
@@ -1922,7 +1967,7 @@ async function retirePendings(
|
|
|
1922
1967
|
updateSessionState(sessionId, ctx.env, (current) => {
|
|
1923
1968
|
const retiring = [...(current.retiring ?? [])]
|
|
1924
1969
|
for (const entry of entries) {
|
|
1925
|
-
const retirement = retiringQuestion(entry, state)
|
|
1970
|
+
const retirement = retiringQuestion(entry, state, envelope.cwd)
|
|
1926
1971
|
if (retirement !== null && !retiring.some((item) => item.request_id === retirement.request_id)) {
|
|
1927
1972
|
retiring.push(retirement)
|
|
1928
1973
|
}
|
|
@@ -2032,7 +2077,7 @@ export async function handleUserPromptSubmit(
|
|
|
2032
2077
|
const retiring = [...(current.retiring ?? [])]
|
|
2033
2078
|
const unasked = pendingList(current).filter((entry) => entry.request_id === undefined)
|
|
2034
2079
|
for (const entry of pendingList(current).filter((entry) => entry.request_id !== undefined)) {
|
|
2035
|
-
const retirement = retiringQuestion(entry, 'answered_elsewhere')
|
|
2080
|
+
const retirement = retiringQuestion(entry, 'answered_elsewhere', envelope.cwd)
|
|
2036
2081
|
if (
|
|
2037
2082
|
retirement !== null &&
|
|
2038
2083
|
!retiring.some((parked) => parked.request_id === retirement.request_id)
|
|
@@ -2040,31 +2085,24 @@ export async function handleUserPromptSubmit(
|
|
|
2040
2085
|
retiring.push(retirement)
|
|
2041
2086
|
}
|
|
2042
2087
|
}
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
// The user typed, so whatever chain of answer-driven continuations was
|
|
2060
|
-
// running, a human has taken the turn and the consecutive count starts
|
|
2061
|
-
// over. Only a real prompt reaches here: the journal branch above returns
|
|
2062
|
-
// first, and a wake route's injected message always arrives while its own
|
|
2063
|
-
// answer is still journaled, so it can never pass for the user's presence.
|
|
2064
|
-
...(current.continuation === undefined
|
|
2065
|
-
? {}
|
|
2066
|
-
: { continuation: { ...current.continuation, count: 0 } }),
|
|
2088
|
+
|
|
2089
|
+
// Start from the whole document so fields introduced by a newer CLI remain
|
|
2090
|
+
// attached while this older writer applies the prompt transition it knows.
|
|
2091
|
+
const next: SessionState = { ...current, last_prompt_at: ctx.now() }
|
|
2092
|
+
if (unasked.length > 0) next.pending = unasked
|
|
2093
|
+
else delete next.pending
|
|
2094
|
+
if (retiring.length > 0) next.retiring = retiring
|
|
2095
|
+
else delete next.retiring
|
|
2096
|
+
|
|
2097
|
+
// The user typed, so whatever chain of answer-driven continuations was
|
|
2098
|
+
// running, a human has taken the turn and the consecutive count starts
|
|
2099
|
+
// over. Only a real prompt reaches here: the journal branch above returns
|
|
2100
|
+
// first, and a wake route's injected message always arrives while its own
|
|
2101
|
+
// answer is still journaled, so it can never pass for the user's presence.
|
|
2102
|
+
if (current.continuation !== undefined) {
|
|
2103
|
+
next.continuation = { ...current.continuation, count: 0 }
|
|
2067
2104
|
}
|
|
2105
|
+
return next
|
|
2068
2106
|
})
|
|
2069
2107
|
// The bridge that lets a plain `notifai ask` find the hook's canonical
|
|
2070
2108
|
// session: an agent shell command gets no hook payload, and not every
|
|
@@ -2188,9 +2226,16 @@ export async function runEscalationWaiter(
|
|
|
2188
2226
|
accepted.delivered_at !== undefined || envelope.stop_hook_active === true
|
|
2189
2227
|
if (!deliveryProven) {
|
|
2190
2228
|
for (const answer of accepted.answers) reportAnswer(ctx, notes, answer, true)
|
|
2191
|
-
return await deliverAcceptedAnswers(
|
|
2229
|
+
return await deliverAcceptedAnswers(
|
|
2230
|
+
ctx,
|
|
2231
|
+
sessionId,
|
|
2232
|
+
options.route,
|
|
2233
|
+
accepted,
|
|
2234
|
+
notes,
|
|
2235
|
+
envelope.cwd,
|
|
2236
|
+
)
|
|
2192
2237
|
}
|
|
2193
|
-
settleAcceptedAnswers(ctx, sessionId, accepted)
|
|
2238
|
+
settleAcceptedAnswers(ctx, sessionId, accepted, envelope.cwd)
|
|
2194
2239
|
settledAnswerThisPass = true
|
|
2195
2240
|
state = readSessionState(sessionId, ctx.env)
|
|
2196
2241
|
const currentAcceptedIds = new Set(
|
|
@@ -2286,6 +2331,7 @@ async function deliverAcceptedAnswers(
|
|
|
2286
2331
|
route: EscalationDeliveryRoute,
|
|
2287
2332
|
accepted: AcceptedAnswerDelivery,
|
|
2288
2333
|
notes: string[],
|
|
2334
|
+
cwd?: string,
|
|
2289
2335
|
): Promise<HookOutcome> {
|
|
2290
2336
|
const { answers: answered, remaining } = accepted
|
|
2291
2337
|
const requestIds = summarizeRequestIds(answered.map((entry) => entry.pending)).ids
|
|
@@ -2297,7 +2343,7 @@ async function deliverAcceptedAnswers(
|
|
|
2297
2343
|
limit: MAX_HELD_DELIVERIES,
|
|
2298
2344
|
request_ids: requestIds,
|
|
2299
2345
|
})
|
|
2300
|
-
settleAcceptedAnswers(ctx, sessionId, accepted)
|
|
2346
|
+
settleAcceptedAnswers(ctx, sessionId, accepted, cwd)
|
|
2301
2347
|
notes.push(
|
|
2302
2348
|
`no route could hand the user's answer over in ${MAX_HELD_DELIVERIES} turns; not holding it any longer`,
|
|
2303
2349
|
)
|
|
@@ -2314,7 +2360,7 @@ async function deliverAcceptedAnswers(
|
|
|
2314
2360
|
// Settle rather than journal for ever. The answer has been offered as often
|
|
2315
2361
|
// as any working route could need, it is already in the notes of each of
|
|
2316
2362
|
// those turns, and the questions it answered still deserve retirement.
|
|
2317
|
-
settleAcceptedAnswers(ctx, sessionId, accepted)
|
|
2363
|
+
settleAcceptedAnswers(ctx, sessionId, accepted, cwd)
|
|
2318
2364
|
notes.push(
|
|
2319
2365
|
`the user's answer reached the ${route.kind} route ${MAX_CONTINUATION_COUNT} times without being acknowledged; not delivering it again`,
|
|
2320
2366
|
)
|
|
@@ -2464,7 +2510,7 @@ async function handleClaimedStop(
|
|
|
2464
2510
|
for (const answer of authoritative) reportAnswer(ctx, notes, answer, true)
|
|
2465
2511
|
// Anything still unasked rides the next Stop: the agent is being resumed
|
|
2466
2512
|
// with answers right now, and may not even need the rest afterwards.
|
|
2467
|
-
return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes)
|
|
2513
|
+
return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes, envelope.cwd)
|
|
2468
2514
|
}
|
|
2469
2515
|
const recoverableLive = live.filter(
|
|
2470
2516
|
(entry) => !permanentFailures.has(entry.request_id!),
|
|
@@ -2611,6 +2657,7 @@ async function escalate(
|
|
|
2611
2657
|
// server accepts answers into changed.
|
|
2612
2658
|
const ownerDeadlineAt = ceilingAt
|
|
2613
2659
|
const questions = pendingQuestions(entry)
|
|
2660
|
+
const eventSource = sourceContextAtHookEvent(entry.source, envelope.cwd)
|
|
2614
2661
|
let intent = entry.submission
|
|
2615
2662
|
if (intent === undefined) {
|
|
2616
2663
|
if (entry.body === undefined) {
|
|
@@ -2624,7 +2671,7 @@ async function escalate(
|
|
|
2624
2671
|
questions,
|
|
2625
2672
|
...(entry.media !== undefined ? { media: entry.media } : {}),
|
|
2626
2673
|
...(entry.project !== undefined ? { project: entry.project } : {}),
|
|
2627
|
-
...(
|
|
2674
|
+
...(eventSource !== undefined ? { source: eventSource } : {}),
|
|
2628
2675
|
event: 'agent_question',
|
|
2629
2676
|
windowSeconds: replyWindowSeconds,
|
|
2630
2677
|
ownerDeadlineAt,
|
|
@@ -2703,7 +2750,7 @@ async function escalate(
|
|
|
2703
2750
|
questions,
|
|
2704
2751
|
...(entry.media !== undefined ? { media: entry.media } : {}),
|
|
2705
2752
|
...(entry.project !== undefined ? { project: entry.project } : {}),
|
|
2706
|
-
...(
|
|
2753
|
+
...(eventSource !== undefined ? { source: eventSource } : {}),
|
|
2707
2754
|
event: 'agent_question',
|
|
2708
2755
|
windowSeconds: replyWindowSeconds,
|
|
2709
2756
|
ownerDeadlineAt,
|
|
@@ -2783,6 +2830,7 @@ async function escalate(
|
|
|
2783
2830
|
: Date.parse(receipt.reply_expires_at)
|
|
2784
2831
|
const live: PendingQuestion = {
|
|
2785
2832
|
...entry,
|
|
2833
|
+
...(intent.draft.source !== undefined ? { source: intent.draft.source } : {}),
|
|
2786
2834
|
request_id: intent.request_id,
|
|
2787
2835
|
collapse_key: intent.collapse_key,
|
|
2788
2836
|
device_ids: intent.device_ids,
|
|
@@ -2802,6 +2850,7 @@ async function escalate(
|
|
|
2802
2850
|
? committedReplyDeadline
|
|
2803
2851
|
: intent.owner_deadline_at,
|
|
2804
2852
|
}
|
|
2853
|
+
if (intent.draft.source === undefined) delete live.source
|
|
2805
2854
|
delete live.submission
|
|
2806
2855
|
submitted.push(live)
|
|
2807
2856
|
// Record what is now live on the user's devices BEFORE any wait. If we
|
|
@@ -2823,7 +2872,7 @@ async function escalate(
|
|
|
2823
2872
|
// The entry vanished while the submit was in flight (the user's prompt
|
|
2824
2873
|
// wiped the queue). The delivered notification must still be retirable,
|
|
2825
2874
|
// so park it rather than lose its only identifiers.
|
|
2826
|
-
const retirement = retiringQuestion(live, 'answered_elsewhere')!
|
|
2875
|
+
const retirement = retiringQuestion(live, 'answered_elsewhere', envelope.cwd)!
|
|
2827
2876
|
const retiring = [...(current.retiring ?? [])]
|
|
2828
2877
|
if (!retiring.some((parked) => parked.request_id === retirement.request_id)) {
|
|
2829
2878
|
retiring.push(retirement)
|
|
@@ -2863,7 +2912,7 @@ async function escalate(
|
|
|
2863
2912
|
)
|
|
2864
2913
|
const accepted = stageAcceptedAnswers(ctx, sessionId, staleAnswers, remaining)
|
|
2865
2914
|
for (const answer of staleAnswers) reportAnswer(ctx, notes, answer, true)
|
|
2866
|
-
return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes)
|
|
2915
|
+
return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes, envelope.cwd)
|
|
2867
2916
|
}
|
|
2868
2917
|
const waitingOn = [
|
|
2869
2918
|
...alreadyLive.filter(
|
|
@@ -2941,7 +2990,7 @@ async function escalate(
|
|
|
2941
2990
|
)
|
|
2942
2991
|
const accepted = stageAcceptedAnswers(ctx, sessionId, finalAnswers, remaining)
|
|
2943
2992
|
for (const answer of finalAnswers) reportAnswer(ctx, notes, answer, false)
|
|
2944
|
-
return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes)
|
|
2993
|
+
return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes, envelope.cwd)
|
|
2945
2994
|
}
|
|
2946
2995
|
const requestIdSummary = summarizeRequestIds(waitingOn)
|
|
2947
2996
|
ctx.log?.info('hook.answer', {
|
|
@@ -2996,7 +3045,7 @@ async function escalate(
|
|
|
2996
3045
|
waitingOn.length - answered.length,
|
|
2997
3046
|
)
|
|
2998
3047
|
for (const answer of answered) reportAnswer(ctx, notes, answer, false)
|
|
2999
|
-
return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes)
|
|
3048
|
+
return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes, envelope.cwd)
|
|
3000
3049
|
}
|
|
3001
3050
|
|
|
3002
3051
|
// ---------------------------------------------------------------------------
|
|
@@ -3025,7 +3074,7 @@ export function handleSessionEnd(
|
|
|
3025
3074
|
const orphans: RetiringQuestion[] = [...(state.retiring ?? [])]
|
|
3026
3075
|
for (const entry of pendingList(state)) {
|
|
3027
3076
|
try {
|
|
3028
|
-
const orphan = retiringQuestion(entry, 'expired')
|
|
3077
|
+
const orphan = retiringQuestion(entry, 'expired', envelope.cwd)
|
|
3029
3078
|
if (orphan !== null) orphans.push(orphan)
|
|
3030
3079
|
} catch (err) {
|
|
3031
3080
|
notes.push(err instanceof Error ? err.message : String(err))
|
|
@@ -3044,14 +3093,13 @@ export function handleSessionEnd(
|
|
|
3044
3093
|
)
|
|
3045
3094
|
}
|
|
3046
3095
|
if (state.accepted !== undefined || (state.acknowledgement_due?.length ?? 0) > 0) {
|
|
3047
|
-
const preserved: SessionState = {}
|
|
3048
|
-
|
|
3049
|
-
|
|
3050
|
-
|
|
3096
|
+
const preserved: SessionState = { ...state }
|
|
3097
|
+
delete preserved.pending
|
|
3098
|
+
delete preserved.retiring
|
|
3099
|
+
delete preserved.acknowledgement_blocks
|
|
3100
|
+
if ((preserved.acknowledgement_due?.length ?? 0) === 0) {
|
|
3101
|
+
delete preserved.acknowledgement_due
|
|
3051
3102
|
}
|
|
3052
|
-
if (state.last_prompt_at !== undefined) preserved.last_prompt_at = state.last_prompt_at
|
|
3053
|
-
if (state.last_stop_at !== undefined) preserved.last_stop_at = state.last_stop_at
|
|
3054
|
-
if (state.continuation !== undefined) preserved.continuation = state.continuation
|
|
3055
3103
|
writeSessionState(sessionId, env, preserved)
|
|
3056
3104
|
notes.push(
|
|
3057
3105
|
state.accepted !== undefined
|
|
@@ -838,11 +838,94 @@ describe('the OpenCode adapter', () => {
|
|
|
838
838
|
expect(source).not.toContain('synthetic: true')
|
|
839
839
|
})
|
|
840
840
|
|
|
841
|
-
it('injects
|
|
841
|
+
it('injects exact identity and the first-party OpenCode title into agent shells', () => {
|
|
842
842
|
expect(source).toContain('"shell.env"')
|
|
843
843
|
expect(source).toContain('NOTIFAI_ACTIVE_HARNESS')
|
|
844
844
|
expect(source).toContain('NOTIFAI_ACTIVE_SESSION_ID')
|
|
845
|
+
expect(source).toContain('NOTIFAI_ACTIVE_SESSION_LABEL')
|
|
846
|
+
expect(source).toContain('NOTIFAI_ACTIVE_SESSION_LABEL_PENDING')
|
|
845
847
|
expect(source).toContain('input?.sessionID')
|
|
848
|
+
expect(source).toContain('client.session.get({ path: { id: sessionID } })')
|
|
849
|
+
expect(source).toContain('response?.data?.title')
|
|
850
|
+
})
|
|
851
|
+
|
|
852
|
+
it('executes the generated adapter and publishes the SDK session title', async () => {
|
|
853
|
+
let requested = ''
|
|
854
|
+
const generated = (await import(
|
|
855
|
+
`data:text/javascript;base64,${Buffer.from(source).toString('base64')}`
|
|
856
|
+
)) as {
|
|
857
|
+
NotifAIPlugin(input: {
|
|
858
|
+
directory: string
|
|
859
|
+
client: {
|
|
860
|
+
session: {
|
|
861
|
+
get(input: { path: { id: string } }): Promise<{ data: { title: string } }>
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
}): Promise<{
|
|
865
|
+
'shell.env'?: (
|
|
866
|
+
input: { sessionID?: string },
|
|
867
|
+
output: { env: Record<string, string> },
|
|
868
|
+
) => Promise<void>
|
|
869
|
+
}>
|
|
870
|
+
}
|
|
871
|
+
const plugin = await generated.NotifAIPlugin({
|
|
872
|
+
directory: '/repo',
|
|
873
|
+
client: {
|
|
874
|
+
session: {
|
|
875
|
+
get: async ({ path: request }) => {
|
|
876
|
+
requested = request.id
|
|
877
|
+
return { data: { title: 'Semantic session names' } }
|
|
878
|
+
},
|
|
879
|
+
},
|
|
880
|
+
},
|
|
881
|
+
})
|
|
882
|
+
const output = { env: {} as Record<string, string> }
|
|
883
|
+
await plugin['shell.env']?.({ sessionID: 'opencode-session' }, output)
|
|
884
|
+
|
|
885
|
+
expect(requested).toBe('opencode-session')
|
|
886
|
+
expect(output.env).toMatchObject({
|
|
887
|
+
NOTIFAI_ACTIVE_HARNESS: 'opencode',
|
|
888
|
+
NOTIFAI_ACTIVE_SESSION_ID: 'opencode-session',
|
|
889
|
+
NOTIFAI_ACTIVE_SESSION_LABEL: 'Semantic session names',
|
|
890
|
+
})
|
|
891
|
+
})
|
|
892
|
+
|
|
893
|
+
it('marks OpenCode placeholder titles as pending instead of publishing them', async () => {
|
|
894
|
+
const generated = (await import(
|
|
895
|
+
`data:text/javascript;base64,${Buffer.from(source).toString('base64')}`
|
|
896
|
+
)) as {
|
|
897
|
+
NotifAIPlugin(input: {
|
|
898
|
+
directory: string
|
|
899
|
+
client: {
|
|
900
|
+
session: {
|
|
901
|
+
get(input: { path: { id: string } }): Promise<{ data: { title: string } }>
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
}): Promise<{
|
|
905
|
+
'shell.env'?: (
|
|
906
|
+
input: { sessionID?: string },
|
|
907
|
+
output: { env: Record<string, string> },
|
|
908
|
+
) => Promise<void>
|
|
909
|
+
}>
|
|
910
|
+
}
|
|
911
|
+
const plugin = await generated.NotifAIPlugin({
|
|
912
|
+
directory: '/repo',
|
|
913
|
+
client: {
|
|
914
|
+
session: {
|
|
915
|
+
get: async () => ({
|
|
916
|
+
data: { title: 'New session - 2026-08-20T13:05:00.000Z' },
|
|
917
|
+
}),
|
|
918
|
+
},
|
|
919
|
+
},
|
|
920
|
+
})
|
|
921
|
+
const output = { env: {} as Record<string, string> }
|
|
922
|
+
await plugin['shell.env']?.({ sessionID: 'opencode-session' }, output)
|
|
923
|
+
|
|
924
|
+
expect(output.env).toEqual({
|
|
925
|
+
NOTIFAI_ACTIVE_HARNESS: 'opencode',
|
|
926
|
+
NOTIFAI_ACTIVE_SESSION_ID: 'opencode-session',
|
|
927
|
+
NOTIFAI_ACTIVE_SESSION_LABEL_PENDING: '1',
|
|
928
|
+
})
|
|
846
929
|
})
|
|
847
930
|
|
|
848
931
|
it('carries the ownership marker so a second checkout replaces it', () => {
|
package/src/interactive.test.ts
CHANGED
|
@@ -58,7 +58,25 @@ function isolatedEnv(cwd: string): NodeJS.ProcessEnv {
|
|
|
58
58
|
function makeClient(): ApiClient {
|
|
59
59
|
return {
|
|
60
60
|
health: async () => true,
|
|
61
|
-
capabilities: async () => ({ schema_version: 1, platform
|
|
61
|
+
capabilities: async (platform = 'ios') => ({ schema_version: 1, platform }),
|
|
62
|
+
compatibility: async () => ({
|
|
63
|
+
cli: {
|
|
64
|
+
state: 'current',
|
|
65
|
+
reason: 'current',
|
|
66
|
+
affected_operation: null,
|
|
67
|
+
recovery_action: null,
|
|
68
|
+
current_version: '5.0.0',
|
|
69
|
+
current_build: null,
|
|
70
|
+
recommended_version: '5.0.0',
|
|
71
|
+
recommended_build: null,
|
|
72
|
+
minimum_version: null,
|
|
73
|
+
minimum_build: null,
|
|
74
|
+
deprecation: null,
|
|
75
|
+
sunset: null,
|
|
76
|
+
},
|
|
77
|
+
platforms: [],
|
|
78
|
+
server_capabilities: ['answer', 'agent_acknowledgement'],
|
|
79
|
+
}),
|
|
62
80
|
listDevices: async () => ({ devices: [] }),
|
|
63
81
|
accessStatus: async () => ({ email: 'rafael@example.test' }),
|
|
64
82
|
} as unknown as ApiClient
|
package/src/interactive.ts
CHANGED
|
@@ -365,7 +365,7 @@ function menuFor(readiness: Readiness): { value: Screen; label: string; hint?: s
|
|
|
365
365
|
if (!signedIn) {
|
|
366
366
|
options.push({ value: 'account', label: 'Sign in', hint: 'approve this machine in your browser' })
|
|
367
367
|
} else if (!hasDevice) {
|
|
368
|
-
options.push({ value: 'devices', label: 'Add a device', hint: 'install the app on your
|
|
368
|
+
options.push({ value: 'devices', label: 'Add a device', hint: 'install the app on your iPhone' })
|
|
369
369
|
} else {
|
|
370
370
|
options.push({
|
|
371
371
|
value: 'test',
|
|
@@ -457,8 +457,8 @@ async function devicesScreen(deps: CommandDeps): Promise<void> {
|
|
|
457
457
|
if (devices.length === 0) {
|
|
458
458
|
clack.note(
|
|
459
459
|
'No devices are registered yet.\n\n' +
|
|
460
|
-
'Install the Notifai
|
|
461
|
-
'same
|
|
460
|
+
'Install the Notifai Companion App on your iPhone, sign in with the\n' +
|
|
461
|
+
'same Account, and allow notifications when it asks.',
|
|
462
462
|
'No devices',
|
|
463
463
|
)
|
|
464
464
|
return
|
|
@@ -733,7 +733,7 @@ async function routingScreen(deps: CommandDeps, readiness: Readiness): Promise<b
|
|
|
733
733
|
const row = (label: string, value: string): string => `${style.dim(label.padEnd(16))}${value}`
|
|
734
734
|
clack.note(
|
|
735
735
|
'When an agent registers a question, it appears in your terminal first.\n' +
|
|
736
|
-
'After the grace below, it goes to your
|
|
736
|
+
'After the grace below, it goes to your iPhone as a notification\n' +
|
|
737
737
|
'with real answer buttons, and your answer comes back to the agent —\n' +
|
|
738
738
|
'whether or not you are still at this machine.\n\n' +
|
|
739
739
|
[
|