spexcode 0.4.1 → 0.4.3
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/README.md +37 -17
- package/package.json +9 -5
- package/spec-cli/README.md +23 -14
- package/spec-cli/hooks/dispatch.sh +7 -5
- package/spec-cli/hooks/harness.sh +19 -8
- package/spec-cli/src/anchors.ts +110 -18
- package/spec-cli/src/cli.ts +88 -37
- package/spec-cli/src/client.ts +34 -11
- package/spec-cli/src/doctor.ts +174 -36
- package/spec-cli/src/gateway-auth.ts +194 -0
- package/spec-cli/src/gateway-hub.ts +360 -0
- package/spec-cli/src/gateway.ts +27 -19
- package/spec-cli/src/git.ts +12 -5
- package/spec-cli/src/graph.ts +54 -43
- package/spec-cli/src/graphStream.ts +265 -34
- package/spec-cli/src/guide.ts +105 -55
- package/spec-cli/src/harness-select.ts +20 -6
- package/spec-cli/src/harness.ts +181 -33
- package/spec-cli/src/help.ts +64 -27
- package/spec-cli/src/host.ts +610 -0
- package/spec-cli/src/identity-presets.d.ts +13 -0
- package/spec-cli/src/identity-presets.js +128 -0
- package/spec-cli/src/index.ts +145 -86
- package/spec-cli/src/init.ts +53 -25
- package/spec-cli/src/layout.ts +5 -5
- package/spec-cli/src/lint.ts +25 -121
- package/spec-cli/src/login-page.ts +12 -5
- package/spec-cli/src/materialize.ts +39 -13
- package/spec-cli/src/mentions.ts +16 -11
- package/spec-cli/src/opencode.ts +145 -0
- package/spec-cli/src/pi-harness.ts +124 -0
- package/spec-cli/src/project-identity.ts +73 -0
- package/spec-cli/src/pty-bridge.ts +321 -521
- package/spec-cli/src/pty-helper.mjs +86 -0
- package/spec-cli/src/reaper.ts +16 -8
- package/spec-cli/src/reviewSnapshot.ts +23 -0
- package/spec-cli/src/reviews.ts +347 -0
- package/spec-cli/src/session-timeline.ts +14 -0
- package/spec-cli/src/sessions.ts +188 -66
- package/spec-cli/src/shim-runtime.ts +169 -0
- package/spec-cli/src/slash-commands.ts +76 -0
- package/spec-cli/src/source-files.ts +79 -0
- package/spec-cli/src/specs.ts +12 -7
- package/spec-cli/src/supervise.ts +37 -15
- package/spec-cli/src/tree.ts +8 -8
- package/spec-cli/src/uninstall.ts +56 -23
- package/spec-cli/templates/hooks/prepare-commit-msg +32 -18
- package/spec-cli/templates/spec/project/.plugins/commands/extract/spec.md +24 -18
- package/spec-cli/templates/spec/project/.plugins/commands/regroup/spec.md +3 -3
- package/spec-cli/templates/spec/project/.plugins/commands/rename/spec.md +11 -0
- package/spec-cli/templates/spec/project/.plugins/commands/spec.md +22 -8
- package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/commands/tidy/spec.md +29 -11
- package/spec-cli/templates/spec/project/.plugins/core/idle/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/mark-active/spec.md +2 -0
- package/spec-cli/templates/spec/project/.plugins/core/session-fail/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/spec-first/spec-first.sh +28 -39
- package/spec-cli/templates/spec/project/.plugins/core/spec-first/spec.md +5 -3
- package/spec-cli/templates/spec/project/.plugins/core/spec-of-file/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/spec.md +2 -2
- package/spec-cli/templates/spec/project/.plugins/core/stop-gate/spec.md +7 -1
- package/spec-cli/templates/spec/project/.plugins/prompts/forge-link/spec.md +2 -2
- package/spec-cli/templates/spec/project/.plugins/prompts/reproduce-before-fix/spec.md +5 -5
- package/spec-cli/templates/spec/project/.plugins/prompts/spec.md +10 -6
- package/spec-cli/templates/spec/project/.plugins/skills/distill/spec.md +38 -50
- package/spec-cli/templates/spec/project/.plugins/skills/spec.md +9 -7
- package/spec-cli/templates/spec/project/.plugins/spec.md +17 -12
- package/spec-cli/templates/spec/project/spec.md +6 -5
- package/spec-cli/templates/spexcode.json +4 -2
- package/spec-dashboard/dist/assets/Dashboard-CiHh-gLD.js +27 -0
- package/spec-dashboard/dist/assets/EvalsPage-DmiX3rdU.js +2 -0
- package/spec-dashboard/dist/assets/IssuesPage-CIbVGRUJ.js +1 -0
- package/spec-dashboard/dist/assets/MobileApp-D-N9_eh0.js +2 -0
- package/spec-dashboard/dist/assets/Modal-DHMzSFJ4.js +1 -0
- package/spec-dashboard/dist/assets/PageScroll-C15adEYI.js +1 -0
- package/spec-dashboard/dist/assets/ProjectsPage-sQpzglp5.js +1 -0
- package/spec-dashboard/dist/assets/SessionInterface-6GBZ9nXN.css +32 -0
- package/spec-dashboard/dist/assets/SessionInterface-B8pGU7Rg.js +39 -0
- package/spec-dashboard/dist/assets/SessionWindow-CuDO_67z.js +13 -0
- package/spec-dashboard/dist/assets/Settings-C_N1wX1f.js +1 -0
- package/spec-dashboard/dist/assets/bindings-BC9vqpYU.js +1 -0
- package/spec-dashboard/dist/assets/index-DmWbmvCq.js +41 -0
- package/spec-dashboard/dist/assets/index-GGIVdKwH.css +1 -0
- package/spec-dashboard/dist/assets/terminalFont-DmTPNdbU.js +1 -0
- package/spec-dashboard/dist/index.html +10 -10
- package/spec-dashboard/src/reviewFilters.js +279 -0
- package/spec-dashboard/src/reviewQuery.js +174 -0
- package/spec-dashboard/src/session.js +167 -0
- package/spec-eval/src/cli.ts +49 -28
- package/spec-eval/src/humanok.ts +1 -1
- package/spec-eval/src/matrix.ts +693 -0
- package/spec-eval/src/sessioneval.ts +726 -66
- package/spec-cli/src/migrate-table.ts +0 -408
- package/spec-cli/src/migrate.ts +0 -386
- package/spec-dashboard/dist/assets/Dashboard-C_fGmOKK.js +0 -27
- package/spec-dashboard/dist/assets/EvalsPage-Cnr1s3bq.js +0 -2
- package/spec-dashboard/dist/assets/FoldToggle-x9gtO1OQ.js +0 -1
- package/spec-dashboard/dist/assets/IssuesPage-5f_vL-JV.js +0 -1
- package/spec-dashboard/dist/assets/MobileApp-DEO1jgGM.js +0 -1
- package/spec-dashboard/dist/assets/SessionInterface-CAlbMOFR.js +0 -66
- package/spec-dashboard/dist/assets/SessionInterface-DYP7pi_n.css +0 -32
- package/spec-dashboard/dist/assets/SessionWindow-JYbpPwNB.js +0 -13
- package/spec-dashboard/dist/assets/Settings-DKb5Ji_X.js +0 -1
- package/spec-dashboard/dist/assets/index-BQu-oJ8J.js +0 -41
- package/spec-dashboard/dist/assets/index-BbMkwuix.css +0 -1
- package/spec-dashboard/dist/assets/launch-BM9GgvkX.js +0 -6
package/spec-cli/src/sessions.ts
CHANGED
|
@@ -2,15 +2,15 @@ import { execFile } from 'node:child_process'
|
|
|
2
2
|
import { promisify } from 'node:util'
|
|
3
3
|
import { randomUUID } from 'node:crypto'
|
|
4
4
|
import { readFileSync, writeFileSync, appendFileSync, existsSync, renameSync, mkdirSync, rmSync, readdirSync, realpathSync, statSync } from 'node:fs'
|
|
5
|
-
import { join, dirname, relative, isAbsolute } from 'node:path'
|
|
5
|
+
import { join, dirname, relative, isAbsolute, resolve, sep } from 'node:path'
|
|
6
6
|
import { fileURLToPath } from 'node:url'
|
|
7
7
|
import { seedWorktreeHostState } from './worktree-sources.js'
|
|
8
8
|
import { git, gitA, gitTry, repoRoot, mergeBaseDiff, mergeConflicts, type ReviewDiffFile } from './git.js'
|
|
9
|
-
import { loadSpecs } from './specs.js'
|
|
10
|
-
import { defaultHarness, defaultLauncher, harnessById, resolveLauncher, rvSock, rendezvousListening, type Harness, type DispatchResult, type PaneProbe, type ProcTable } from './harness.js'
|
|
9
|
+
import { loadConfig, loadSpecs, type ConfigPreset, type SpecLite } from './specs.js'
|
|
10
|
+
import { defaultHarness, defaultLauncher, harnessById, procSnapshot, resolveLauncher, rvSock, rendezvousListening, type Harness, type DispatchResult, type PaneProbe, type ProcTable } from './harness.js'
|
|
11
11
|
import { materialize } from './materialize.js'
|
|
12
12
|
import { mainBranch, gitCommonDir, readConfig, runtimeRoot, treeSlotDir, sessionStoreDir, sessionRecordPath, sessionArtifactPath, listSessionIds, readAliasedRawRecord, envSessionId, type RawRecord } from './layout.js'
|
|
13
|
-
import { recordSent } from './session-timeline.js'
|
|
13
|
+
import { recordSent, lastHumanSendVia } from './session-timeline.js'
|
|
14
14
|
import { stripRefSigil } from './mentions.js'
|
|
15
15
|
|
|
16
16
|
// @@@ sessions - the WORKTREE is the durable unit; tmux is a disposable runtime handle. The per-session
|
|
@@ -44,9 +44,9 @@ import { stripRefSigil } from './mentions.js'
|
|
|
44
44
|
// auto-disappears; to withdraw one you MESSAGE the session (mark-active clears it), and a relaunch (resume)
|
|
45
45
|
// deliberately does NOT touch it. `merges` is METADATA (how many times merged), shown as a badge, not a state.
|
|
46
46
|
//
|
|
47
|
-
// Launch rules
|
|
48
|
-
//
|
|
49
|
-
//
|
|
47
|
+
// Launch rules: private `tmux -L <label>` socket + the session's pinned named-launcher command. The launcher
|
|
48
|
+
// preserves its harness's normal permission model unless the user explicitly configured an automatic-permission
|
|
49
|
+
// command. SPEXCODE_TMUX overrides the tmux socket for tests; no env var rewrites the launcher.
|
|
50
50
|
|
|
51
51
|
const pexec = promisify(execFile)
|
|
52
52
|
export const TMUX_SOCK = process.env.SPEXCODE_TMUX || 'spexcode'
|
|
@@ -87,7 +87,7 @@ const rvEnv = (id: string, harness = HARNESS) => {
|
|
|
87
87
|
// start from the payload thread id and alias through harness_session_id, while the short-lived codex-launch
|
|
88
88
|
// process uses this env only to store the freshly started thread id on the governed record. The CLAUDE_BG
|
|
89
89
|
// rendezvous control socket is the reclaude prompt-delivery path and exists ONLY for harnesses that own one
|
|
90
|
-
// (claude) — codex has no such daemon, so it
|
|
90
|
+
// (claude/pi/opencode) — codex has no such daemon, so it is omitted there.
|
|
91
91
|
const parts = [`SPEXCODE_SESSION_ID=${id}`]
|
|
92
92
|
if (harness.ownsRendezvous) parts.push(`CLAUDE_BG_BACKEND=daemon`, `CLAUDE_BG_RENDEZVOUS_SOCK=${rvSock(id)}`)
|
|
93
93
|
for (const v of ['SPEXCODE_HOME', 'CODEX_HOME']) { const val = process.env[v]; if (val) parts.push(`${v}=${val}`) }
|
|
@@ -229,9 +229,34 @@ export type SessRec = {
|
|
|
229
229
|
sortKey: number | null; createdAt: number; harness: string; harnessSessionId: string | null
|
|
230
230
|
launcher: string | null // the launcher profile this session launches under ([[launcher-select]]); null only for old records predating launchers
|
|
231
231
|
launchCmd: string | null // the RESOLVED base launcher command pinned at creation ([[launcher-select]] resume-launcher-pin); null → old record → fall back to the launcher name / ambient
|
|
232
|
+
launchOwner: string | null // stable public-backend authority while queued; null for active/legacy records
|
|
232
233
|
}
|
|
233
234
|
const LIFECYCLES = new Set<Lifecycle>(['active', 'idle', 'awaiting', 'parked', 'error', 'asking', 'queued'])
|
|
234
235
|
const PROPOSALS = new Set<Proposal>(['merge', 'nothing', 'close'])
|
|
236
|
+
export const OWNED_QUEUE_RAW_STATUS = 'launch-queued'
|
|
237
|
+
|
|
238
|
+
// @@@ stable launch authority - the supervisor injects its PUBLIC proxy URL into every replaceable child.
|
|
239
|
+
// That URL survives child hot reload/restart; PORT inside a supervised child is private and ephemeral, so it
|
|
240
|
+
// is only the fallback for a directly-run server with no injected API URL. Credentials/query/fragment are
|
|
241
|
+
// not authority and may contain secrets, so they are stripped before the value reaches session.json.
|
|
242
|
+
export function backendLaunchAuthority(env: { SPEXCODE_API_URL?: string; PORT?: string } = process.env): string {
|
|
243
|
+
const raw = env.SPEXCODE_API_URL?.trim() || `http://127.0.0.1:${env.PORT?.trim() || '8787'}`
|
|
244
|
+
const url = new URL(raw)
|
|
245
|
+
url.username = ''
|
|
246
|
+
url.password = ''
|
|
247
|
+
url.search = ''
|
|
248
|
+
url.hash = ''
|
|
249
|
+
url.pathname = url.pathname.replace(/\/+$/, '') || '/'
|
|
250
|
+
return url.toString().replace(/\/$/, '')
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export function rawLifecycleStatus(rec: Pick<SessRec, 'status' | 'launchOwner'>): string {
|
|
254
|
+
return rec.status === 'queued' && rec.launchOwner ? OWNED_QUEUE_RAW_STATUS : rec.status
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export function canDrainQueued(rec: Pick<SessRec, 'status' | 'launchOwner'>, authority = backendLaunchAuthority()): boolean {
|
|
258
|
+
return rec.status === 'queued' && (rec.launchOwner === null || rec.launchOwner === authority)
|
|
259
|
+
}
|
|
235
260
|
|
|
236
261
|
// typed read of a session's record from the global store (null if it has none — a self-launched session that
|
|
237
262
|
// only ever wrote spec-discipline sentinels has a store dir but no session.json). Goes through layout's
|
|
@@ -242,8 +267,13 @@ function readRecord(id: string): SessRec | null {
|
|
|
242
267
|
if (!raw) return null
|
|
243
268
|
return fromRaw(raw)
|
|
244
269
|
}
|
|
245
|
-
|
|
246
|
-
|
|
270
|
+
// the loose on-disk fields validated into the typed shape. Exported so the old-record defaults (harness →
|
|
271
|
+
// claude, absent pin → null) are unit-auditable without a store on disk.
|
|
272
|
+
export function fromRaw(raw: RawRecord & { launch_owner?: string }): SessRec {
|
|
273
|
+
const ownedQueue = raw.status === OWNED_QUEUE_RAW_STATUS
|
|
274
|
+
const status = ownedQueue ? 'queued' : LIFECYCLES.has(raw.status as Lifecycle) ? raw.status as Lifecycle : 'active'
|
|
275
|
+
const launchOwner = ownedQueue ? raw.launch_owner?.trim() : null
|
|
276
|
+
if (ownedQueue && !launchOwner) throw new Error(`owned queue record '${raw.session_id}' has no launch_owner`)
|
|
247
277
|
const proposal = raw.proposal && PROPOSALS.has(raw.proposal as Proposal) ? raw.proposal as Proposal : null
|
|
248
278
|
const sk = raw.sortkey
|
|
249
279
|
const sortKey = typeof sk === 'number' && Number.isFinite(sk) ? sk : null
|
|
@@ -255,6 +285,7 @@ function fromRaw(raw: RawRecord): SessRec {
|
|
|
255
285
|
harnessSessionId: raw.harness_session_id || null,
|
|
256
286
|
launcher: raw.launcher || null, // records written before launchers → null → old-record fallback
|
|
257
287
|
launchCmd: raw.launch_cmd || null, // records written before the pin → null → fall back to launcher name / ambient
|
|
288
|
+
launchOwner: launchOwner || null,
|
|
258
289
|
}
|
|
259
290
|
}
|
|
260
291
|
// @@@ session.json format - written one-field-per-line (JSON.stringify(_, null, 2)) with EVERY key ALWAYS
|
|
@@ -271,7 +302,10 @@ function writeRecord(rec: SessRec): void {
|
|
|
271
302
|
title: rec.title ?? '',
|
|
272
303
|
name: rec.name ?? '',
|
|
273
304
|
parent: rec.parent ?? '',
|
|
274
|
-
|
|
305
|
+
// A leased queue uses a raw token older drainers do not recognize as `queued`; current readers map it
|
|
306
|
+
// back to the unchanged public lifecycle. This version fence is what keeps an orphaned old backend from
|
|
307
|
+
// stealing the entry before it can even inspect the new launch_owner field.
|
|
308
|
+
status: rawLifecycleStatus(rec),
|
|
275
309
|
proposal: rec.proposal ?? '',
|
|
276
310
|
merges: rec.merges,
|
|
277
311
|
note: rec.note ?? '',
|
|
@@ -281,6 +315,7 @@ function writeRecord(rec: SessRec): void {
|
|
|
281
315
|
harness_session_id: rec.harnessSessionId ?? '',
|
|
282
316
|
launcher: rec.launcher ?? '',
|
|
283
317
|
launch_cmd: rec.launchCmd ?? '',
|
|
318
|
+
launch_owner: rec.status === 'queued' ? rec.launchOwner ?? '' : '',
|
|
284
319
|
}
|
|
285
320
|
mkdirSync(sessionStoreDir(rec.session), { recursive: true })
|
|
286
321
|
writeFileSync(sessionRecordPath(rec.session), JSON.stringify(obj, null, 2) + '\n')
|
|
@@ -323,16 +358,7 @@ async function listWorktrees(): Promise<{ path: string; branch: string | null }[
|
|
|
323
358
|
// wrapper, even while the TUI renders, so the foreground name is NOT the signal). CLAUDE ignores the probe —
|
|
324
359
|
// its workers launch through the `reclaude` wrapper, which runs claude as a CHILD, so claude liveness stays
|
|
325
360
|
// its rendezvous socket. The per-session alive() above stays for the single-session ops (capture / rawKey).
|
|
326
|
-
|
|
327
|
-
const t: ProcTable = new Map()
|
|
328
|
-
let out = ''
|
|
329
|
-
try { ({ stdout: out } = await pexec('ps', ['-eo', 'pid=,ppid=,comm='], { timeout: TMUX_PROBE_TIMEOUT_MS, killSignal: 'SIGKILL' })) } catch { return t }
|
|
330
|
-
for (const line of out.split('\n')) {
|
|
331
|
-
const m = /^\s*(\d+)\s+(\d+)\s+(.*)$/.exec(line)
|
|
332
|
-
if (m) t.set(Number(m[1]), { ppid: Number(m[2]), comm: m[3].trim() })
|
|
333
|
-
}
|
|
334
|
-
return t
|
|
335
|
-
}
|
|
361
|
+
// (the whole-box ps snapshot itself — procSnapshot — lives in harness.ts beside its tree-walk consumers.)
|
|
336
362
|
// @@@ LiveSnap - the ONE liveness snapshot the whole session list shares, built from a SINGLE tmux spawn
|
|
337
363
|
// (`list-panes -a` yields every session's window presence, pane pid, AND pane title at once — every session has
|
|
338
364
|
// ≥1 pane). `windows` = our live tmux windows (id → PaneProbe: pane pid + the hot-tier `pidAlive` verdict + the
|
|
@@ -520,7 +546,7 @@ export function selfSummary(paneTitle: string): string | null {
|
|
|
520
546
|
// instead of 'offline' for BOOT_GRACE_MS after launch — so 'offline' only ever means genuinely dead. In-
|
|
521
547
|
// memory in the single server process (lost on restart, which is fine: a restart has nothing in flight).
|
|
522
548
|
const launchedAt = new Map<string, number>()
|
|
523
|
-
const BOOT_GRACE_MS = 45000 // > SOCKET_READY_TIMEOUT_MS, and spans launchScript's bounded fast-fail retry
|
|
549
|
+
export const BOOT_GRACE_MS = 45000 // > SOCKET_READY_TIMEOUT_MS, and spans launchScript's bounded fast-fail retry
|
|
524
550
|
// window (~3 attempts) so a relaunching session reads 'starting', not 'offline'
|
|
525
551
|
const LAUNCH_FAST_FAIL_S = 12 // launchScript retries the agent command when it exits faster than this: fast
|
|
526
552
|
// exit before readiness is retryable, but it is not proof of one specific cause
|
|
@@ -540,10 +566,10 @@ const LAUNCH_FAST_FAIL_S = 12 // launchScript retries the agent command when it
|
|
|
540
566
|
export function liveness(rec: SessRec, snap: LiveSnap): Liveness {
|
|
541
567
|
if (!rec.session) return 'offline'
|
|
542
568
|
if (snap.probeFailed) return 'unknown' // the probe failed — we can't tell, and MUST NOT guess offline
|
|
543
|
-
//
|
|
544
|
-
//
|
|
545
|
-
//
|
|
546
|
-
//
|
|
569
|
+
// Ask the resolved ADAPTER ([[harness-adapter]]): claude/pi/opencode prove their rendezvous listener;
|
|
570
|
+
// codex proves its launch-registered pid (with the legacy descendant-tree fallback). The 'starting' grace
|
|
571
|
+
// stays here: a just-launched agent whose online signal has not appeared yet reads 'starting', only past it
|
|
572
|
+
// 'offline'.
|
|
547
573
|
const h = harnessById(rec.harness || defaultHarness.id)
|
|
548
574
|
if (h.liveness(rec, snap.windows.has(rec.session), runtimeRoot(), snap.windows.get(rec.session), snap.sockets.has(rec.session)) === 'online') return 'online'
|
|
549
575
|
// not provably online — but if this session's LISTENER probe couldn't conclude (timeout under load / EAGAIN
|
|
@@ -869,9 +895,20 @@ export function withSenderHint(text: string, sender: MsgSender | null): string {
|
|
|
869
895
|
// the declaration NOTE ([[session-timeline]]). This one-line insert tells the agent exactly that, so its
|
|
870
896
|
// next stop carries the complete answer in `--note` instead of prose that dies in an unseen terminal.
|
|
871
897
|
// Appended server-side (the input route passes replyVia:'note'), so the phrase lives in ONE place and any
|
|
872
|
-
// surface — desktop included, later — can opt in with the same flag.
|
|
898
|
+
// surface — desktop included, later — can opt in with the same flag. The notice declares itself
|
|
899
|
+
// PER-MESSAGE, and withTerminalReplyHint (below) is its counter-signal: without both, an agent that
|
|
900
|
+
// note-replied a few times keeps note-replying from context inertia long after the human is back at a
|
|
901
|
+
// terminal — the sticky-note failure this pair exists to prevent.
|
|
873
902
|
export const withNoteReplyHint = (text: string): string =>
|
|
874
|
-
`${text}\n\n— sent from a terminal-free client: the sender CANNOT see your terminal output. When you next stop (done/ask/park), put your COMPLETE reply to this message in the declaration's --note (e.g. spex session done --note "<full answer>") — the note is the only text that reaches them.`
|
|
903
|
+
`${text}\n\n— sent from a terminal-free client: the sender CANNOT see your terminal output. When you next stop (done/ask/park), put your COMPLETE reply to this message in the declaration's --note (e.g. spex session done --note "<full answer>") — the note is the only text that reaches them. This notice is PER-MESSAGE: a later message arriving WITHOUT it means the sender is back at a terminal and reads your normal output again.`
|
|
904
|
+
// @@@ withTerminalReplyHint - the BACK-AT-A-TERMINAL counter-insert, appended exactly once at the
|
|
905
|
+
// note→terminal transition (a human send with no note flag whose previous human send carried one —
|
|
906
|
+
// [[session-timeline]] lastHumanSendVia). It explicitly countermands the note-reply instruction: telling
|
|
907
|
+
// the agent once beats hoping it scopes the earlier per-message notice correctly (it demonstrably doesn't —
|
|
908
|
+
// codex especially). Not repeated on later terminal sends: once the transition send is recorded, the last
|
|
909
|
+
// human channel is no longer note, so ordinary conversation stays insert-free.
|
|
910
|
+
export const withTerminalReplyHint = (text: string): string =>
|
|
911
|
+
`${text}\n\n— sent from a terminal-attached client: the sender now reads your terminal output directly. Reply in your normal conversation output from here on — stop putting replies in declaration --notes (the earlier terminal-free notices no longer apply; a --note can go back to being a short status line).`
|
|
875
912
|
async function postJSON(path: string, body: unknown): Promise<void> {
|
|
876
913
|
try {
|
|
877
914
|
await fetch(`${await apiBase()}${path}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
|
|
@@ -905,10 +942,10 @@ export const isBackendUnreachable = (e: unknown): boolean =>
|
|
|
905
942
|
export const slugify = (s: string | null) =>
|
|
906
943
|
(s || 'session').normalize('NFC').replace(/[^\p{L}\p{N}_-]+/gu, '-').replace(/-+/g, '-').replace(/^-+|-+$/g, '') || 'session'
|
|
907
944
|
|
|
908
|
-
// @@@ node + title from the prompt - the spec node a session works on is
|
|
909
|
-
//
|
|
910
|
-
// actually left in the prompt
|
|
911
|
-
//
|
|
945
|
+
// @@@ node + title from the prompt - the spec node a session works on is the FIRST `[[<id>]]` topic
|
|
946
|
+
// reference in the raw prompt ([[mentions]]: `[[node]]` is a topic, `@` is an actor/session). The node the
|
|
947
|
+
// user actually left in the prompt is the truth: there is no focused-node/API/function argument that can
|
|
948
|
+
// grant a binding outside the task text. Changing or deleting the mention changes or removes the binding.
|
|
912
949
|
// When there is none, the session is node-agnostic and we label it by the first few words of the prompt.
|
|
913
950
|
// The OPTIONAL leading dot is load-bearing: a node id is its dir basename, so a dot-prefixed config root
|
|
914
951
|
// (`.plugins`) keeps the dot — without `\.?` here `[[.plugins]]` captures nothing and never resolves to a node.
|
|
@@ -916,7 +953,47 @@ export const slugify = (s: string | null) =>
|
|
|
916
953
|
// id, so `[[中文节点]]` must bind the session exactly like an ASCII id — ASCII-only here silently launched
|
|
917
954
|
// node-agnostic.
|
|
918
955
|
const MENTION = /\[\[(\.?[\p{L}\p{N}_-]+)\]\]/u
|
|
919
|
-
const
|
|
956
|
+
export const nodeFromPrompt = (prompt: string): string | null => prompt.match(MENTION)?.[1] ?? null
|
|
957
|
+
|
|
958
|
+
type CommandPreset = Pick<ConfigPreset, 'name' | 'body'>
|
|
959
|
+
type CommandSpec = Pick<SpecLite, 'id' | 'path'>
|
|
960
|
+
|
|
961
|
+
// @@@ command invocation - turn the raw `/<preset> [[node]]… free text` into the ONE agent prompt.
|
|
962
|
+
// This is deliberately server-side: dashboard, phone, CLI, direct API, and the in-process fallback all call
|
|
963
|
+
// the launch/send boundary, so no client gets its own command interpreter. Launch keeps the RAW prompt for
|
|
964
|
+
// session identity/history; only the agent payload uses this expansion, preventing a plugin body's own
|
|
965
|
+
// [[links]] from becoming the session node. With no mention, a preset remains targetless.
|
|
966
|
+
export function composeCommandPrompt(raw: string, presets: CommandPreset[], specs: CommandSpec[]): string {
|
|
967
|
+
const match = raw.match(/^\/(\S+)\s*([\s\S]*)$/)
|
|
968
|
+
if (!match) return raw
|
|
969
|
+
const preset = presets.find((p) => p.name === match[1])
|
|
970
|
+
if (!preset) return raw
|
|
971
|
+
|
|
972
|
+
const ids: string[] = []
|
|
973
|
+
const allMentions = new RegExp(MENTION.source, 'gu')
|
|
974
|
+
const free = match[2].replace(allMentions, (_, id: string) => { ids.push(id); return '' }).trim()
|
|
975
|
+
const targets = ids.length
|
|
976
|
+
? ids.map((id) => {
|
|
977
|
+
const spec = specs.find((s) => s.id === id)
|
|
978
|
+
const path = spec?.path.replace(/^\.spec\//, '').replace(/\/spec\.md$/, '')
|
|
979
|
+
return path ? `- [[${id}]] — ${path}` : `- [[${id}]]`
|
|
980
|
+
}).join('\n')
|
|
981
|
+
: '(No target was mentioned. If the prompt names the scope, use it; otherwise ask the human to define the scope before proceeding — unless this task needs no scope, in which case proceed.)'
|
|
982
|
+
const body = preset.body.includes('{{targets}}')
|
|
983
|
+
? preset.body.replace('{{targets}}', targets)
|
|
984
|
+
: ids.length ? `${preset.body}\n\n${targets}` : preset.body
|
|
985
|
+
return free ? `${body}\n\n${free}` : body
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
// Load only the one live preset named by the raw invocation. Both newSession and sendText call this seam, so
|
|
989
|
+
// launch and an existing session's inbox resolve identical plugin data with identical target semantics.
|
|
990
|
+
export async function resolveCommandPrompt(raw: string, loadedSpecs?: CommandSpec[]): Promise<string> {
|
|
991
|
+
const commandName = raw.match(/^\/(\S+)/)?.[1]
|
|
992
|
+
const preset = commandName ? loadConfig().find((p) => p.name === commandName) : undefined
|
|
993
|
+
if (!preset) return raw
|
|
994
|
+
const specs = loadedSpecs ?? (nodeFromPrompt(raw) ? await loadSpecs() : [])
|
|
995
|
+
return composeCommandPrompt(raw, [preset], specs)
|
|
996
|
+
}
|
|
920
997
|
// @@@ identity-token strip - an `@session` actor mention ([[mentions]]) or a bare UUID-shaped token in the
|
|
921
998
|
// prompt is ANOTHER session's identity, never this one's name. A title/slug wearing it misleads every
|
|
922
999
|
// board/git surface — and a worker tasked with cleaning that session can match its OWN worktree and delete
|
|
@@ -1014,7 +1091,7 @@ function isOccupying(s: Session, snap: LiveSnap): boolean {
|
|
|
1014
1091
|
if (!OCCUPIES_SLOT.has(s.status)) return false // waiting-on-human / proposed / queued / dead → free
|
|
1015
1092
|
const rec = readRecord(s.id)
|
|
1016
1093
|
if (!rec) return false
|
|
1017
|
-
return harnessById(rec.harness || defaultHarness.id).liveness(rec, snap.windows.has(rec.session), runtimeRoot(), snap.windows.get(rec.session), snap.sockets.has(rec.session)) === 'online'
|
|
1094
|
+
return harnessById(rec.harness || defaultHarness.id).liveness(rec, snap.windows.has(rec.session), runtimeRoot(), snap.windows.get(rec.session), snap.sockets.has(rec.session)) === 'online'
|
|
1018
1095
|
}
|
|
1019
1096
|
// sessions we've JUST launched whose agent hasn't come online yet. During that boot window reconcile reads them
|
|
1020
1097
|
// `offline` (the adapter's online-signal not up yet) and isOccupying would miss them, so the drainer would
|
|
@@ -1030,6 +1107,7 @@ let draining = false // re-entrancy guard: only one drain pass runs at a time
|
|
|
1030
1107
|
async function startQueued(id: string): Promise<boolean> {
|
|
1031
1108
|
const wt = await findWorktree(id)
|
|
1032
1109
|
if (!wt) return false
|
|
1110
|
+
if (!canDrainQueued(wt.rec)) return false
|
|
1033
1111
|
const launchPrompt = readLaunchFile(id)
|
|
1034
1112
|
if (launchPrompt == null) return false // a queued session always has one; if it's gone, don't spin on it
|
|
1035
1113
|
launching.add(id) // hold the slot across the boot window BEFORE we launch, so a concurrent count can't race us
|
|
@@ -1041,7 +1119,7 @@ async function startQueued(id: string): Promise<boolean> {
|
|
|
1041
1119
|
launching.delete(id)
|
|
1042
1120
|
return false // launch failed → stays `queued`, retried on the next drain tick
|
|
1043
1121
|
}
|
|
1044
|
-
writeRecord({ ...wt.rec, status: 'active', proposal: null })
|
|
1122
|
+
writeRecord({ ...wt.rec, status: 'active', proposal: null, launchOwner: null })
|
|
1045
1123
|
removeLaunchFile(id) // consumed
|
|
1046
1124
|
// release the boot-window hold once the socket is up (then isOccupying takes over) or after the bounded
|
|
1047
1125
|
// wait — so a launch that never booted reads offline and the drainer reclaims the slot instead of pinning it.
|
|
@@ -1068,7 +1146,12 @@ export async function drainQueue(): Promise<void> {
|
|
|
1068
1146
|
if (snap.probeFailed) break
|
|
1069
1147
|
const occupied = sessions.reduce((n, s) => n + (launching.has(s.id) || isOccupying(s, snap) ? 1 : 0), 0)
|
|
1070
1148
|
if (occupied >= cap) break
|
|
1071
|
-
const
|
|
1149
|
+
const authority = backendLaunchAuthority()
|
|
1150
|
+
const next = sessions.find((s) => {
|
|
1151
|
+
if (s.status !== 'queued' || launching.has(s.id)) return false
|
|
1152
|
+
const rec = readRecord(s.id)
|
|
1153
|
+
return !!rec && canDrainQueued(rec, authority)
|
|
1154
|
+
})
|
|
1072
1155
|
if (!next) break
|
|
1073
1156
|
if (!(await startQueued(next.id))) break // launch failed → stop this pass; a later tick retries
|
|
1074
1157
|
}
|
|
@@ -1125,6 +1208,29 @@ export async function assertProjectMatch(verb: string): Promise<void> {
|
|
|
1125
1208
|
}
|
|
1126
1209
|
}
|
|
1127
1210
|
|
|
1211
|
+
type SessionCreateFn = (prompt: string, parent: string | null, launcher?: string) => Promise<Session>
|
|
1212
|
+
export type SessionCreateRequestResult =
|
|
1213
|
+
| { status: 201; session: Session }
|
|
1214
|
+
| { status: 400; error: string }
|
|
1215
|
+
|
|
1216
|
+
// The API create boundary accepts one small, closed object shape. Unknown fields fail through this generic
|
|
1217
|
+
// contract before any worktree is made; removed or misspelled inputs never disappear into defaults.
|
|
1218
|
+
export async function sessionCreateRequest(body: unknown, create: SessionCreateFn = newSession): Promise<SessionCreateRequestResult> {
|
|
1219
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) return { status: 400, error: 'body must be a JSON object' }
|
|
1220
|
+
const input = body as Record<string, unknown>
|
|
1221
|
+
const unknown = Object.keys(input).filter((key) => !['prompt', 'parent', 'launcher'].includes(key)).sort()
|
|
1222
|
+
if (unknown.length) return { status: 400, error: `unknown session-create field${unknown.length === 1 ? '' : 's'}: ${unknown.join(', ')}` }
|
|
1223
|
+
const prompt = typeof input.prompt === 'string' ? input.prompt : ''
|
|
1224
|
+
if (!prompt.trim()) return { status: 400, error: 'empty prompt' }
|
|
1225
|
+
const launcher = typeof input.launcher === 'string' && input.launcher.trim() ? input.launcher.trim() : undefined
|
|
1226
|
+
const parent = typeof input.parent === 'string' && input.parent.trim() ? input.parent.trim() : null
|
|
1227
|
+
try {
|
|
1228
|
+
return { status: 201, session: await create(prompt, parent, launcher) }
|
|
1229
|
+
} catch (e) {
|
|
1230
|
+
return { status: 400, error: String((e as Error).message || e) }
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1128
1234
|
// @@@ createSession (dispatch via backend) - `spex session new` must launch the worker in the
|
|
1129
1235
|
// BACKEND's process, not the caller's, because the backend is the single owner of the concurrency cap and the
|
|
1130
1236
|
// launch QUEUE (drainQueue). An in-process launch by an agent that runs `spex session new` (e.g. a supervisor) would
|
|
@@ -1133,7 +1239,7 @@ export async function assertProjectMatch(verb: string): Promise<void> {
|
|
|
1133
1239
|
// either process.) So the CLI POSTs to the running backend whenever one answers. Only when NO backend is
|
|
1134
1240
|
// reachable do we fall back to launching in this process (with a stderr warning) — the backend's own POST
|
|
1135
1241
|
// handler calls newSession directly, so it never re-enters this path.
|
|
1136
|
-
export async function createSession(
|
|
1242
|
+
export async function createSession(prompt: string, launcher?: string): Promise<Session> {
|
|
1137
1243
|
await assertProjectMatch('spex session new')
|
|
1138
1244
|
// @@@ parent = the CALLER's own session ([[session-nesting]]). Resolve it HERE, in the caller's process,
|
|
1139
1245
|
// via the SAME ownSessionId env read [[agent-reply-channel]] uses for its sender hint — NOT inside the
|
|
@@ -1145,11 +1251,11 @@ export async function createSession(node: string | null, prompt: string, launche
|
|
|
1145
1251
|
res = await fetch(`${await apiBase()}/api/sessions`, {
|
|
1146
1252
|
method: 'POST',
|
|
1147
1253
|
headers: { 'content-type': 'application/json' },
|
|
1148
|
-
body: JSON.stringify({
|
|
1254
|
+
body: JSON.stringify({ prompt, parent, launcher }),
|
|
1149
1255
|
})
|
|
1150
1256
|
} catch {
|
|
1151
1257
|
console.error('spex: no backend reachable — launching in-process (caller env owns auth, no concurrency cap)')
|
|
1152
|
-
return newSession(
|
|
1258
|
+
return newSession(prompt, parent, launcher)
|
|
1153
1259
|
}
|
|
1154
1260
|
if (!res.ok) {
|
|
1155
1261
|
const text = await res.text().catch(() => '')
|
|
@@ -1169,7 +1275,7 @@ export async function createSession(node: string | null, prompt: string, launche
|
|
|
1169
1275
|
// launched agent does itself (the composer's nn/dd chords just prefill a plain instruction). So the server
|
|
1170
1276
|
// only ever launches a session; it never mutates the spec tree ([[mentions]]: the issue store is the sole
|
|
1171
1277
|
// programmatic surface, every other surface is prompt only).
|
|
1172
|
-
export async function newSession(
|
|
1278
|
+
export async function newSession(prompt: string, parent: string | null = null, launcher?: string): Promise<Session> {
|
|
1173
1279
|
const id = randomUUID()
|
|
1174
1280
|
// a launcher ([[launcher-select]]) fixes BOTH the launch command (persisted below) AND the harness — so
|
|
1175
1281
|
// picking one is the ONLY launch choice. Explicit --launcher wins, else the configured defaultLauncher.
|
|
@@ -1177,10 +1283,17 @@ export async function newSession(node: string | null, prompt: string, parent: st
|
|
|
1177
1283
|
const lname = launcher ?? defaultLauncher(mainRoot())
|
|
1178
1284
|
const chosen = resolveLauncher(lname)
|
|
1179
1285
|
const h = harnessById(chosen.harness)
|
|
1180
|
-
|
|
1181
|
-
//
|
|
1182
|
-
|
|
1183
|
-
|
|
1286
|
+
const pinned = h.baseCmd(chosen.cmd)
|
|
1287
|
+
// Resolve a command preset at the shared backend prompt boundary, before any worktree exists. The RAW prompt remains the
|
|
1288
|
+
// identity + originating-prompt source; only `launchPrompt` is expanded for the agent. This preserves the
|
|
1289
|
+
// no-target rule even when the plugin body itself contains `[[links]]`.
|
|
1290
|
+
const rawPrompt = prompt
|
|
1291
|
+
// node identity + label: the RAW prompt's first `[[id]]` topic ref is the only binding channel; expanded
|
|
1292
|
+
// plugin prose is payload only and can never invent scope.
|
|
1293
|
+
const ref = nodeFromPrompt(rawPrompt)
|
|
1294
|
+
const launchSpecs = ref ? await loadSpecs() : null
|
|
1295
|
+
let launchPrompt = await resolveCommandPrompt(rawPrompt, launchSpecs ?? undefined)
|
|
1296
|
+
const title = ref ? null : titleFromPrompt(rawPrompt)
|
|
1184
1297
|
const slug = `${slugify(ref || title)}-${id.slice(0, 4)}`
|
|
1185
1298
|
const branch = `node/${slug}`
|
|
1186
1299
|
const path = join(mainRoot(), '.worktrees', slug)
|
|
@@ -1202,28 +1315,28 @@ export async function newSession(node: string | null, prompt: string, parent: st
|
|
|
1202
1315
|
node: ref || null, title, name: null, parent: parent && parent !== id ? parent : null,
|
|
1203
1316
|
status: 'queued', proposal: null, merges: 0, note: null, sortKey: null, createdAt: Date.now(),
|
|
1204
1317
|
harness: h.id, harnessSessionId: null, launcher: chosen.name,
|
|
1205
|
-
// PIN the resolved
|
|
1318
|
+
// PIN the resolved launch command NOW ([[launcher-select]] resume-launcher-pin) so every future
|
|
1206
1319
|
// (re)launch replays THIS exact launcher — the one whose config-dir env holds the conversation — instead of
|
|
1207
1320
|
// re-resolving against a default that may have flipped (a backend restarted under a different launcher).
|
|
1208
|
-
launchCmd:
|
|
1321
|
+
launchCmd: pinned,
|
|
1322
|
+
launchOwner: backendLaunchAuthority(),
|
|
1209
1323
|
}
|
|
1210
1324
|
writeRecord(rec)
|
|
1211
|
-
writePromptFile(id,
|
|
1325
|
+
writePromptFile(id, rawPrompt) // capture the ORIGINATING prompt (the human/manager's ask), not expanded plugin prose
|
|
1212
1326
|
// materialize the harness-discovered artifacts INTO the worktree (CLAUDE.md/AGENTS.md contract block, .claude/.codex
|
|
1213
1327
|
// shims, manifest to the global store) so the launched agent gets the contract + hooks the SAME way a
|
|
1214
1328
|
// self-launched one does — by auto-discovery, not CLI injection. This is why the launch line below carries no
|
|
1215
1329
|
// --append-system-prompt / --settings, and why we no longer hide CLAUDE.md: hiding it suppressed the agent's
|
|
1216
|
-
// own memory load too.
|
|
1330
|
+
// own memory load too.
|
|
1217
1331
|
bootstrapMaterialize(rec)
|
|
1218
|
-
let launchPrompt = prompt
|
|
1219
1332
|
if (ref) {
|
|
1220
|
-
// @@@ spec pointer - the
|
|
1333
|
+
// @@@ spec pointer - the prompt's first [[id]] ref named an EXISTING node.
|
|
1221
1334
|
// Append ONE line pointing the agent at that node's spec.md as an ABSOLUTE path INSIDE its own worktree, so
|
|
1222
1335
|
// it reads the LIVE file (never a stale snapshot we'd inject). relPath already carries the .spec/ prefix and
|
|
1223
1336
|
// is identical in this freshly-branched worktree, so the absolute path is just join(worktree, relPath). Only
|
|
1224
1337
|
// a real node gets a pointer; an unknown id resolves to nothing and we fail quiet (no pointer appended).
|
|
1225
|
-
const spec =
|
|
1226
|
-
if (spec) launchPrompt = `${
|
|
1338
|
+
const spec = launchSpecs?.find((n) => n.id === ref)
|
|
1339
|
+
if (spec) launchPrompt = `${launchPrompt}\n\nThe spec node \`${ref}\` is your ground truth — read its spec at ${join(path, spec.path)}.`
|
|
1227
1340
|
}
|
|
1228
1341
|
writeLaunchFile(id, launchPrompt) // park the exact launch prompt for the drainer (consumed at launch)
|
|
1229
1342
|
await drainQueue() // launch now if under the cap, else leave it queued for a free slot
|
|
@@ -1297,7 +1410,7 @@ async function waitForReady(id: string, harness: Harness, timeoutMs = SOCKET_REA
|
|
|
1297
1410
|
// session that is proposing a merge must NOT silently withdraw it. Only applied when we actually relaunch;
|
|
1298
1411
|
// a refusal leaves the record wholly untouched.
|
|
1299
1412
|
// Fail-loud is unchanged: if the agent never comes online, the later deliver() fails loud.
|
|
1300
|
-
export async function resumeSession(id: string, opts: { force?: boolean; guard?: boolean } = {}): Promise<{ ok: boolean; error?: string; refused?: boolean }> {
|
|
1413
|
+
export async function resumeSession(id: string, opts: { force?: boolean; guard?: boolean } = {}): Promise<{ ok: boolean; error?: string; refused?: boolean; info?: string }> {
|
|
1301
1414
|
const { force = false, guard = true } = opts
|
|
1302
1415
|
const wt = await findWorktree(id)
|
|
1303
1416
|
if (!wt) return { ok: false, error: `no such session ${id}` }
|
|
@@ -1312,7 +1425,7 @@ export async function resumeSession(id: string, opts: { force?: boolean; guard?:
|
|
|
1312
1425
|
writeRecord({ ...wt.rec, status: wt.rec.status === 'active' ? 'idle' : wt.rec.status })
|
|
1313
1426
|
if (force || lv === 'offline') {
|
|
1314
1427
|
await tmuxOk(['kill-session', '-t', id]) // drop a dead/offline pane (or a force-killed live one)
|
|
1315
|
-
await launch(id, wt.path, h.resumeArg(wt.rec).trim(), h, launcherCmd(wt.rec))
|
|
1428
|
+
await launch(id, wt.path, h.resumeArg(wt.rec).trim(), h, launcherCmd(wt.rec))
|
|
1316
1429
|
await waitForReady(id, h) // a relaunched agent is "ready" only once the adapter reads it online
|
|
1317
1430
|
}
|
|
1318
1431
|
return { ok: true }
|
|
@@ -1611,26 +1724,31 @@ const ANSI: Record<DisplayStatus, string> = {
|
|
|
1611
1724
|
}
|
|
1612
1725
|
|
|
1613
1726
|
// @@@ session selectors - the ONE matcher every session command shares (see [[session-selectors]]). A
|
|
1614
|
-
// selector matches a session iff it is the session's full id, an id-PREFIX, its node,
|
|
1727
|
+
// selector matches a session iff it is the session's full id, an id-PREFIX, its node, its branch, or `.` for
|
|
1728
|
+
// the caller's own launched session. This is
|
|
1615
1729
|
// the single predicate; selectSessions (MANY) and resolveSession (ONE) both call it, so id-prefix/node/branch
|
|
1616
1730
|
// resolution can never drift between "which sessions ls/watch/wait/graph show" and "which session
|
|
1617
1731
|
// review/merge/send/close act on".
|
|
1618
|
-
export function matchesSelector(s: Session, q: string): boolean {
|
|
1732
|
+
export function matchesSelector(s: Session, q: string, own = ownSessionId(), cwd = process.cwd()): boolean {
|
|
1619
1733
|
// a selector may be a comma-separated list (the same convention as `--status a,b`): it matches iff ANY part
|
|
1620
1734
|
// names the session, so `watch a,b` and `watch a b` are equivalent. A single name is the one-part case. This
|
|
1621
1735
|
// is what stops a comma-joined selector from silently matching nothing — an id/node/branch never holds a
|
|
1622
1736
|
// comma, so without the split `a,b` would be one literal selector that matches no session and streams in
|
|
1623
1737
|
// silence forever. Each part sheds an optional reference sigil (stripRefSigil): `@<sel>` / `[[<sel>]]` name
|
|
1624
1738
|
// the same session as the bare token, so the dashboard's mention grammar is tolerated in every CLI selector.
|
|
1739
|
+
const sessionPath = s.path ? resolve(s.path) : null
|
|
1740
|
+
const callerPath = resolve(cwd)
|
|
1741
|
+
const self = Boolean(own) && s.id === own
|
|
1742
|
+
|| Boolean(sessionPath) && (callerPath === sessionPath || callerPath.startsWith(`${sessionPath}${sep}`))
|
|
1625
1743
|
return q.split(',').map((p) => stripRefSigil(p.trim())).filter(Boolean)
|
|
1626
|
-
.some((p) => s.id === p || s.id.startsWith(p) || s.node === p || s.branch === p)
|
|
1744
|
+
.some((p) => p === '.' ? self : s.id === p || s.id.startsWith(p) || s.node === p || s.branch === p)
|
|
1627
1745
|
}
|
|
1628
1746
|
|
|
1629
1747
|
// no selectors (or '@all') = everything. Optional status filter on top. This IS the ls/watch subscription.
|
|
1630
|
-
export function selectSessions(all: Session[], selectors: string[], statuses?: string[]): Session[] {
|
|
1748
|
+
export function selectSessions(all: Session[], selectors: string[], statuses?: string[], own = ownSessionId(), cwd = process.cwd()): Session[] {
|
|
1631
1749
|
let out = all
|
|
1632
1750
|
const sel = selectors.filter((x) => x && x !== '@all')
|
|
1633
|
-
if (sel.length) out = out.filter((s) => sel.some((q) => matchesSelector(s, q)))
|
|
1751
|
+
if (sel.length) out = out.filter((s) => sel.some((q) => matchesSelector(s, q, own, cwd)))
|
|
1634
1752
|
if (statuses && statuses.length) out = out.filter((s) => statuses.includes(s.status))
|
|
1635
1753
|
return out
|
|
1636
1754
|
}
|
|
@@ -1642,11 +1760,11 @@ export function selectSessions(all: Session[], selectors: string[], statuses?: s
|
|
|
1642
1760
|
// precisely: an exact full-id hit wins outright (never reported ambiguous just for prefixing a longer id);
|
|
1643
1761
|
// otherwise a lone match is `ok`, several is `ambiguous` (a prefix/node hitting many), none is `none`.
|
|
1644
1762
|
export type Resolved = { ok: Session } | { ambiguous: Session[] } | { none: true }
|
|
1645
|
-
export function resolveSession(selector: string, sessions: Session[]): Resolved {
|
|
1763
|
+
export function resolveSession(selector: string, sessions: Session[], own = ownSessionId(), cwd = process.cwd()): Resolved {
|
|
1646
1764
|
// the exact-id check sheds the optional sigil too, so `@<full-id>` keeps the exact-wins-over-prefix rule
|
|
1647
1765
|
const exact = sessions.find((s) => s.id === stripRefSigil(selector))
|
|
1648
1766
|
if (exact) return { ok: exact }
|
|
1649
|
-
const hits = sessions.filter((s) => matchesSelector(s, selector))
|
|
1767
|
+
const hits = sessions.filter((s) => matchesSelector(s, selector, own, cwd))
|
|
1650
1768
|
if (hits.length === 1) return { ok: hits[0] }
|
|
1651
1769
|
return hits.length ? { ambiguous: hits } : { none: true }
|
|
1652
1770
|
}
|
|
@@ -1874,10 +1992,14 @@ export async function sendText(id: string, text: string, from?: string, opts: {
|
|
|
1874
1992
|
if (blocked) return { ok: false, error: blocked }
|
|
1875
1993
|
} catch { /* no pane to consult — let the delivery channel decide */ }
|
|
1876
1994
|
}
|
|
1877
|
-
|
|
1878
|
-
//
|
|
1879
|
-
//
|
|
1880
|
-
|
|
1995
|
+
const prompt = await resolveCommandPrompt(text)
|
|
1996
|
+
// a terminal-free sender's dispatch carries the note-reply insert; a human send WITHOUT the flag whose
|
|
1997
|
+
// previous human send carried it is the note→terminal transition and gets the one-shot counter-insert
|
|
1998
|
+
// ([[session-timeline]]). Both appended here, beside the delivery, so every input surface shares the one
|
|
1999
|
+
// phrase pair and the timeline records the message WITHOUT it (the hint is transport, not conversation).
|
|
2000
|
+
const wrapped = opts.replyVia === 'note' ? withNoteReplyHint(prompt)
|
|
2001
|
+
: !from && lastHumanSendVia(id) === 'note' ? withTerminalReplyHint(prompt) : prompt
|
|
2002
|
+
const r = await h.deliver({ ...rec, runtimeDir: runtimeRoot() }, wrapped)
|
|
1881
2003
|
// record the delivered agent-to-agent message ([[comms-edge]]): only when it carries a sender (an agent
|
|
1882
2004
|
// send, not a raw human dispatch) and actually landed. Fire-and-forget — never gates the send result.
|
|
1883
2005
|
if (r.ok && from) void recordComms(id, from)
|
|
@@ -1890,7 +2012,7 @@ export async function sendText(id: string, text: string, from?: string, opts: {
|
|
|
1890
2012
|
// socket. Two channels, two jobs: the socket INJECTS a whole prompt (text + submit), which can drive the
|
|
1891
2013
|
// agent's normal prompt but CANNOT navigate an interactive TUI select menu (e.g. `/model`'s list — ↑/↓ to
|
|
1892
2014
|
// move, ←/→ to adjust, Enter to set, `s` for this-session, Esc to cancel). When the agent is in that
|
|
1893
|
-
// keystroke-navigation state its input box is replaced by the menu, so the
|
|
2015
|
+
// keystroke-navigation state its input box is replaced by the menu, so the CLI raw-key fallback forwards
|
|
1894
2016
|
// each key here in real time. send-keys is exactly right for single raw keys: named keys map to tmux's own
|
|
1895
2017
|
// key names; a single printable char is sent literally (`-l`) so tmux doesn't reinterpret it. The dashboard
|
|
1896
2018
|
// also drives the agent with MODIFIER COMBOS — a terminal's three modifiers carried as a `C-`/`M-`/`S-`
|