mixdog 0.9.123 → 0.9.124
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/package.json +2 -2
- package/src/rules/lead/01-general.md +2 -4
- package/src/rules/shared/01-tool.md +17 -12
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +15 -1
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +9 -0
- package/src/runtime/memory/index.mjs +18 -0
- package/src/runtime/memory/lib/core-memory-file.mjs +161 -0
- package/src/runtime/memory/lib/cycle-scheduler.mjs +4 -0
- package/src/runtime/memory/lib/memory-action-handlers.mjs +5 -0
- package/src/session-runtime/cwd-plugins.mjs +12 -17
- package/src/standalone/channel-transport.mjs +6 -1
- package/src/standalone/daemon.mjs +35 -21
- package/src/standalone/session-client.mjs +105 -26
- package/src/standalone/session-service.mjs +8 -1
- package/src/tui/app/transcript-window.mjs +1 -3
- package/src/tui/components/prompt-input/escape-policy.mjs +8 -6
- package/src/tui/dist/index.mjs +3 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mixdog",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.124",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Standalone mixdog coding-agent CLI/TUI workspace.",
|
|
@@ -63,7 +63,7 @@
|
|
|
63
63
|
"test:release-assets": "node --check scripts/verify-release-assets.mjs && node --check scripts/release-gate-test.mjs && node --test scripts/release-gate-test.mjs",
|
|
64
64
|
"test:release-focused": "npm run test:release-critical && npm run test:compact && npm run test:shellhardening && npm run test:tool-contracts && npm run test:session && npm run test:session-transport",
|
|
65
65
|
"test:release-critical": "npm run test:release-assets && npm run smoke:patch && npm run test:providers",
|
|
66
|
-
"test:session-transport": "node --test scripts/session-transport-test.mjs",
|
|
66
|
+
"test:session-transport": "node --test scripts/session-transport-test.mjs scripts/daemon-bootstrap-test.mjs",
|
|
67
67
|
"test:session": "node --test scripts/runtime-turn-contract-test.mjs scripts/session-save-fault-store-test.mjs",
|
|
68
68
|
"test:media": "node --test src/runtime/media/store.test.mjs src/runtime/media/renditions.test.mjs src/runtime/media/adapters/codex-image.test.mjs",
|
|
69
69
|
"failures": "node scripts/tool-failures.mjs",
|
|
@@ -10,7 +10,5 @@
|
|
|
10
10
|
- Ask only for decisions.
|
|
11
11
|
- Build only the requested scope; trust internal and framework guarantees.
|
|
12
12
|
- Mid-task: replacement supersedes; addition folds in; status gets a brief
|
|
13
|
-
answer while work continues. After compaction, resume the summary
|
|
14
|
-
|
|
15
|
-
- Final text ends the turn only when done. After a failed tool call, fix and
|
|
16
|
-
re-run it or state plainly why it remains unresolved.
|
|
13
|
+
answer while work continues. After compaction, resume the summary.
|
|
14
|
+
- Final text ends the turn only when done.
|
|
@@ -21,20 +21,27 @@
|
|
|
21
21
|
- Plan the fewest dependent rounds, then the fewest calls. Known state —
|
|
22
22
|
anything the task supplied, a tool returned (applied patches and envelope
|
|
23
23
|
hints included), or a check already proved — is never re-found,
|
|
24
|
-
re-derived, or re-verified; a change
|
|
25
|
-
calls iff none needs another's output or can change
|
|
26
|
-
inputs/state; otherwise serialize. Before each batch, deduplicate
|
|
27
|
-
facets still required by the request, route each once to the cheapest
|
|
24
|
+
re-derived, or re-verified; a change re-opens only affected evidence and
|
|
25
|
+
postconditions. Batch calls iff none needs another's output or can change
|
|
26
|
+
another's inputs/state; otherwise serialize. Before each batch, deduplicate
|
|
27
|
+
the facets still required by the request, route each once to the cheapest
|
|
28
28
|
sufficient tool with all required variants/scopes, and launch every
|
|
29
|
-
independent call
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
29
|
+
independent call, diagnostic, experiment variant, and validation case
|
|
30
|
+
together in one batch or script — never split or duplicate a facet across
|
|
31
|
+
tools, mutate merely to widen retrieval, reserve known work, or cap fanout.
|
|
32
|
+
Guesses go wide in one batch, scopes narrow only on verified cues — returned
|
|
33
|
+
siblings/conventions or known literals — and returned output is fully mined
|
|
34
|
+
before the next round. Symbol relations end at
|
|
34
35
|
`code_graph`; values/locations end at the context grep returns; `read`
|
|
35
36
|
covers only what returned spans cannot, as an anchored offset/limit
|
|
36
37
|
window. A conclusive result ends its facet; evidence that determines the
|
|
37
38
|
answer, edit, or deliverable ends retrieval — patch if needed.
|
|
39
|
+
- Converge before opening another round: collect all known failures, make one
|
|
40
|
+
cohesive correction, then one integrated verification. Passed postconditions
|
|
41
|
+
stay closed unless affected. Rerun a failed check or command only after a
|
|
42
|
+
change that can alter its outcome; start a new experiment or dependent round
|
|
43
|
+
only from evidence produced by the prior result. Else switch route or report
|
|
44
|
+
it unresolved.
|
|
38
45
|
- If inspection can change evidence or durable state, use read-only means;
|
|
39
46
|
mutate only when the deliverable requires it, first preserving evidence
|
|
40
47
|
at risk. Never mutate merely to clear an obstacle or unexpected state;
|
|
@@ -52,9 +59,7 @@
|
|
|
52
59
|
echoing a claim; changes made through `shell` verify under the same
|
|
53
60
|
one-batch contract — one script proving every postcondition, value-level
|
|
54
61
|
included, never one check per round; a postcondition that did not
|
|
55
|
-
actually run is unresolved, not passed. Retry only failed envelopes
|
|
56
|
-
rerun a failed check only after a change that can alter its outcome —
|
|
57
|
-
commands alike; else switch route or report it unresolved.
|
|
62
|
+
actually run is unresolved, not passed. Retry only failed envelopes.
|
|
58
63
|
Hand-authored text is edited only with `apply_patch`; computed artifacts
|
|
59
64
|
(data/reports/derived values) come from `shell` computation, never
|
|
60
65
|
hand-transcribed numbers. Earlier `shell` is only for runtime/state
|
|
@@ -327,6 +327,8 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
327
327
|
const providerMetadata = resp.providerMetadata && typeof resp.providerMetadata === 'object'
|
|
328
328
|
? resp.providerMetadata
|
|
329
329
|
: null;
|
|
330
|
+
const stopReason = resp.stopReason ?? resp.stop_reason ?? null;
|
|
331
|
+
const terminationReason = resp.terminationReason ?? null;
|
|
330
332
|
// Anthropic native server-tool turns (web search / code execution /
|
|
331
333
|
// native MCP) carry `server_tool_use` + `*_tool_result` blocks that
|
|
332
334
|
// exist ONLY in this ordered verbatim list — they cannot be rebuilt
|
|
@@ -358,6 +360,8 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
358
360
|
...(reasoningItems ? { reasoningItems } : {}),
|
|
359
361
|
...(reasoningContent ? { reasoningContent } : {}),
|
|
360
362
|
...(providerMetadata ? { providerMetadata } : {}),
|
|
363
|
+
...(stopReason ? { stopReason } : {}),
|
|
364
|
+
...(terminationReason ? { terminationReason } : {}),
|
|
361
365
|
}, opts);
|
|
362
366
|
messages.push(message);
|
|
363
367
|
try { opts.onAssistantMessageCommitted?.(message); } catch {}
|
|
@@ -832,12 +836,22 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
832
836
|
};
|
|
833
837
|
break;
|
|
834
838
|
}
|
|
835
|
-
if (
|
|
839
|
+
if (stopReason === 'refusal') {
|
|
836
840
|
if (_refusalRetryUsed) {
|
|
837
841
|
process.stderr.write(`[loop] safety-classifier refusal persisted after one context-changing retry (sess=${sessionId || 'unknown'}); ending loop as refusal termination.\n`);
|
|
838
842
|
break;
|
|
839
843
|
}
|
|
840
844
|
_refusalRetryUsed = true;
|
|
845
|
+
// A provider may emit harmless narration before its safety
|
|
846
|
+
// classifier terminates the turn. Preserve that partial turn
|
|
847
|
+
// and its stop reason, but never mistake the non-empty text for
|
|
848
|
+
// a successful completion.
|
|
849
|
+
if (hasContent && pushIntermediateAssistantResponse(response)) {
|
|
850
|
+
if (!suppressMidTurnText) {
|
|
851
|
+
_committedTextParts.push(response.content);
|
|
852
|
+
try { opts.onAssistantText?.(response.content); } catch { /* best-effort */ }
|
|
853
|
+
}
|
|
854
|
+
}
|
|
841
855
|
messages.push({
|
|
842
856
|
role: 'user',
|
|
843
857
|
content: '[mixdog-runtime] The previous completion was refused by the provider safety classifier (stopReason=refusal). Do not repeat it. Complete your assigned output within policy by omitting or reframing disallowed content; if no compliant output is possible, briefly state the refusal.',
|
|
@@ -807,6 +807,7 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
|
|
|
807
807
|
const persistedAssistantContent = typeof result.historyContent === 'string'
|
|
808
808
|
? result.historyContent
|
|
809
809
|
: (result.content || '');
|
|
810
|
+
const _terminalStop = result?.stopReason ?? result?.stop_reason ?? null;
|
|
810
811
|
session.messages.push({
|
|
811
812
|
role: 'assistant',
|
|
812
813
|
// Keep content as-is in memory (model-visible). Image bytes,
|
|
@@ -820,6 +821,14 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
|
|
|
820
821
|
...(result.providerMetadata && typeof result.providerMetadata === 'object'
|
|
821
822
|
? { providerMetadata: result.providerMetadata }
|
|
822
823
|
: {}),
|
|
824
|
+
// Keep terminal provider evidence for non-empty turns too.
|
|
825
|
+
// A safety classifier can emit narration and then refuse;
|
|
826
|
+
// omitting this metadata made that shape indistinguishable
|
|
827
|
+
// from an ordinary successful final response.
|
|
828
|
+
...(_terminalStop ? { stopReason: _terminalStop } : {}),
|
|
829
|
+
...(result?.terminationReason ? { terminationReason: result.terminationReason } : {}),
|
|
830
|
+
iterations: result?.iterations ?? null,
|
|
831
|
+
toolCallsTotal: result?.toolCallsTotal ?? null,
|
|
823
832
|
});
|
|
824
833
|
} else {
|
|
825
834
|
// Empty terminal turn: still persist a forensic record so
|
|
@@ -66,6 +66,7 @@ import { pruneOldEntries } from './lib/memory-maintenance-store.mjs'
|
|
|
66
66
|
import { computeEntryScore } from './lib/memory-score.mjs'
|
|
67
67
|
import { runFullBackfill } from './lib/memory-ops-policy.mjs'
|
|
68
68
|
import { listCore, addCore, editCore, deleteCore, compactCoreIds, listCoreCandidates, promoteCoreCandidate, dismissCoreCandidate } from './lib/core-memory-store.mjs'
|
|
69
|
+
import { refreshCoreMemoryFile } from './lib/core-memory-file.mjs'
|
|
69
70
|
import { resolveProjectId, resolveProjectScope } from './lib/project-id-resolver.mjs'
|
|
70
71
|
import { openTraceDatabase, closeTraceDatabase, insertTraceEvents, enqueueTraceEvents, insertAgentCalls, registerTraceExitDrain } from './lib/trace-store.mjs'
|
|
71
72
|
import { updateJsonAtomicSync, writeJsonAtomicSync } from '../shared/atomic-file.mjs'
|
|
@@ -375,6 +376,17 @@ async function setCycleLastRun(kind, ts) {
|
|
|
375
376
|
await mergeMetaValue(db, CYCLE_LAST_RUN_KEY, { [kind]: ts })
|
|
376
377
|
}
|
|
377
378
|
|
|
379
|
+
async function refreshCoreMemorySnapshot(reason = 'mutation') {
|
|
380
|
+
try {
|
|
381
|
+
const result = await refreshCoreMemoryFile(db, DATA_DIR)
|
|
382
|
+
memoryProfile('core-memory:file-refreshed', { reason, revision: result.revision, written: result.written })
|
|
383
|
+
return result
|
|
384
|
+
} catch (error) {
|
|
385
|
+
__mixdogMemoryLog(`[core-memory] file refresh failed (${reason}): ${error?.message || error}\n`)
|
|
386
|
+
return null
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
378
390
|
|
|
379
391
|
// ── Cycle scheduling cluster (extracted to lib/cycle-scheduler.mjs) ────────
|
|
380
392
|
// The mutually-referential cycle machinery (health ledger, cycle1 outer
|
|
@@ -413,6 +425,7 @@ const _cycleScheduler = createCycleScheduler({
|
|
|
413
425
|
scheduledCycle2Signature,
|
|
414
426
|
scheduledCycle3Signature,
|
|
415
427
|
cycleStateFile: CYCLE_STATE_FILE,
|
|
428
|
+
onCoreMemoryChanged: refreshCoreMemorySnapshot,
|
|
416
429
|
})
|
|
417
430
|
// Cycle1 run primitives + cycle2 finalize used by MCP action handlers below.
|
|
418
431
|
const _startCycle1Run = _cycleScheduler.startCycle1Run
|
|
@@ -444,6 +457,10 @@ async function _initRuntime() {
|
|
|
444
457
|
const compactStartedAt = performance.now()
|
|
445
458
|
await compactCoreIds(DATA_DIR)
|
|
446
459
|
memoryProfile('core-ids:compact:done', { ms: (performance.now() - compactStartedAt).toFixed(1) })
|
|
460
|
+
// First boot migrates existing PG core data; later boots reconcile any
|
|
461
|
+
// crash window between a committed PG mutation and its atomic file refresh.
|
|
462
|
+
// The snapshot is not part of memory readiness.
|
|
463
|
+
void refreshCoreMemorySnapshot('boot-migration')
|
|
447
464
|
// Memory module is always-on: the transcript watcher/ingest runs
|
|
448
465
|
// unconditionally except in secondary mode (secondary attaches to a primary's
|
|
449
466
|
// PG and must not double-ingest). The recap toggle only gates whether the
|
|
@@ -517,6 +534,7 @@ const _actionHandlers = createMemoryActionHandlers({
|
|
|
517
534
|
finalizeCycle2Run: _finalizeCycle2Run,
|
|
518
535
|
finalizeCycle3Run: _finalizeCycle3Run,
|
|
519
536
|
requestCycle3Review: _cycleScheduler.requestCycle3Review,
|
|
537
|
+
refreshCoreMemoryFile: refreshCoreMemorySnapshot,
|
|
520
538
|
getSchedulerCycle1InFlight: () => _cycleScheduler.getCycle1InFlight(),
|
|
521
539
|
getCycle2CallLlm,
|
|
522
540
|
getCycle3CallLlm,
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { updateJsonAtomic } from '../../shared/atomic-file.mjs'
|
|
4
|
+
import { resolveProjectScope } from './project-id-resolver.mjs'
|
|
5
|
+
|
|
6
|
+
export const CORE_MEMORY_FILE_VERSION = 1
|
|
7
|
+
export const CORE_MEMORY_FILE_NAME = 'core-memory.json'
|
|
8
|
+
|
|
9
|
+
const reservedRevisions = new Map()
|
|
10
|
+
|
|
11
|
+
function filePath(dataDir) {
|
|
12
|
+
return join(dataDir, CORE_MEMORY_FILE_NAME)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function finiteNumber(value, fallback = 0) {
|
|
16
|
+
const number = Number(value)
|
|
17
|
+
return Number.isFinite(number) ? number : fallback
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function normalizeProjectId(value) {
|
|
21
|
+
if (value == null) return null
|
|
22
|
+
const text = String(value).trim()
|
|
23
|
+
return text || null
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function normalizeCuratedEntry(row) {
|
|
27
|
+
const summary = String(row?.summary || '').replace(/\s+/g, ' ').trim()
|
|
28
|
+
if (!summary) return null
|
|
29
|
+
return {
|
|
30
|
+
id: finiteNumber(row?.id),
|
|
31
|
+
summary,
|
|
32
|
+
projectId: normalizeProjectId(row?.projectId ?? row?.project_id),
|
|
33
|
+
updatedAt: finiteNumber(row?.updatedAt ?? row?.updated_at),
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function normalizeGeneratedEntry(row) {
|
|
38
|
+
const summary = String(row?.summary ?? row?.core_summary ?? '').replace(/\s+/g, ' ').trim()
|
|
39
|
+
if (!summary) return null
|
|
40
|
+
return {
|
|
41
|
+
summary,
|
|
42
|
+
projectId: normalizeProjectId(row?.projectId ?? row?.project_id),
|
|
43
|
+
score: finiteNumber(row?.score),
|
|
44
|
+
lastSeenAt: finiteNumber(row?.lastSeenAt ?? row?.last_seen_at),
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function normalizeSnapshot(snapshot = {}) {
|
|
49
|
+
return {
|
|
50
|
+
curated: (Array.isArray(snapshot.curated) ? snapshot.curated : [])
|
|
51
|
+
.map(normalizeCuratedEntry)
|
|
52
|
+
.filter(Boolean),
|
|
53
|
+
generated: (Array.isArray(snapshot.generated) ? snapshot.generated : [])
|
|
54
|
+
.map(normalizeGeneratedEntry)
|
|
55
|
+
.filter(Boolean),
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function readCoreMemoryFile(dataDir) {
|
|
60
|
+
try {
|
|
61
|
+
const parsed = JSON.parse(readFileSync(filePath(dataDir), 'utf8'))
|
|
62
|
+
if (parsed?.version !== CORE_MEMORY_FILE_VERSION) return null
|
|
63
|
+
const snapshot = normalizeSnapshot(parsed)
|
|
64
|
+
return {
|
|
65
|
+
version: CORE_MEMORY_FILE_VERSION,
|
|
66
|
+
revision: Math.max(0, finiteNumber(parsed.revision)),
|
|
67
|
+
updatedAt: Math.max(0, finiteNumber(parsed.updatedAt)),
|
|
68
|
+
...snapshot,
|
|
69
|
+
}
|
|
70
|
+
} catch {
|
|
71
|
+
return null
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function reserveRevision(dataDir) {
|
|
76
|
+
const path = filePath(dataDir)
|
|
77
|
+
const diskRevision = readCoreMemoryFile(dataDir)?.revision || 0
|
|
78
|
+
const revision = Math.max(diskRevision, reservedRevisions.get(path) || 0) + 1
|
|
79
|
+
reservedRevisions.set(path, revision)
|
|
80
|
+
return revision
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function writeCoreMemoryFileSnapshot(dataDir, snapshot, { revision = reserveRevision(dataDir), now = Date.now() } = {}) {
|
|
84
|
+
const normalized = normalizeSnapshot(snapshot)
|
|
85
|
+
const requestedRevision = Math.max(1, finiteNumber(revision, 1))
|
|
86
|
+
const result = await updateJsonAtomic(filePath(dataDir), (current) => {
|
|
87
|
+
const currentRevision = Math.max(0, finiteNumber(current?.revision))
|
|
88
|
+
// A slower refresh may finish after a newer one. Never let its older PG
|
|
89
|
+
// snapshot overwrite the newer file.
|
|
90
|
+
if (current?.version === CORE_MEMORY_FILE_VERSION && currentRevision >= requestedRevision) {
|
|
91
|
+
return undefined
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
version: CORE_MEMORY_FILE_VERSION,
|
|
95
|
+
revision: requestedRevision,
|
|
96
|
+
updatedAt: finiteNumber(now, Date.now()),
|
|
97
|
+
...normalized,
|
|
98
|
+
}
|
|
99
|
+
}, { secret: true, compact: true })
|
|
100
|
+
return {
|
|
101
|
+
revision: Math.max(0, finiteNumber(result?.revision)),
|
|
102
|
+
written: finiteNumber(result?.revision) === requestedRevision,
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function refreshCoreMemoryFile(db, dataDir) {
|
|
107
|
+
// Reserve before querying: if concurrent refreshes complete out of order,
|
|
108
|
+
// the atomic revision guard rejects the stale result.
|
|
109
|
+
const revision = reserveRevision(dataDir)
|
|
110
|
+
const [curatedResult, generatedResult] = await Promise.all([
|
|
111
|
+
db.query(`
|
|
112
|
+
SELECT id, summary, project_id, updated_at
|
|
113
|
+
FROM core_entries
|
|
114
|
+
WHERE status IS NULL OR status = 'active'
|
|
115
|
+
ORDER BY project_id NULLS FIRST, id ASC
|
|
116
|
+
`),
|
|
117
|
+
db.query(`
|
|
118
|
+
SELECT core_summary, project_id, score, last_seen_at
|
|
119
|
+
FROM (
|
|
120
|
+
SELECT core_summary, project_id, score, last_seen_at,
|
|
121
|
+
ROW_NUMBER() OVER (
|
|
122
|
+
PARTITION BY project_id
|
|
123
|
+
ORDER BY score DESC, last_seen_at DESC
|
|
124
|
+
) AS scope_rank
|
|
125
|
+
FROM entries
|
|
126
|
+
WHERE is_root = 1
|
|
127
|
+
AND status = 'active'
|
|
128
|
+
AND core_summary IS NOT NULL
|
|
129
|
+
) ranked
|
|
130
|
+
WHERE scope_rank <= 40
|
|
131
|
+
ORDER BY project_id NULLS FIRST, scope_rank ASC
|
|
132
|
+
`),
|
|
133
|
+
])
|
|
134
|
+
return await writeCoreMemoryFileSnapshot(dataDir, {
|
|
135
|
+
curated: curatedResult?.rows || [],
|
|
136
|
+
generated: generatedResult?.rows || [],
|
|
137
|
+
}, { revision })
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function readSessionCoreMemoryPayload(dataDir, cwd) {
|
|
141
|
+
const file = readCoreMemoryFile(dataDir)
|
|
142
|
+
if (!file) return null
|
|
143
|
+
const projectId = resolveProjectScope(typeof cwd === 'string' && cwd ? cwd : null)
|
|
144
|
+
const inScope = (entry) => entry.projectId === null || entry.projectId === projectId
|
|
145
|
+
const curated = file.curated
|
|
146
|
+
.filter(inScope)
|
|
147
|
+
.sort((a, b) => {
|
|
148
|
+
if (a.projectId === null && b.projectId !== null) return -1
|
|
149
|
+
if (a.projectId !== null && b.projectId === null) return 1
|
|
150
|
+
return a.id - b.id
|
|
151
|
+
})
|
|
152
|
+
const generated = file.generated
|
|
153
|
+
.filter(inScope)
|
|
154
|
+
.sort((a, b) => b.score - a.score || b.lastSeenAt - a.lastSeenAt)
|
|
155
|
+
return {
|
|
156
|
+
projectId,
|
|
157
|
+
revision: file.revision,
|
|
158
|
+
userLines: curated.map((entry) => entry.summary),
|
|
159
|
+
dbLines: generated.map((entry) => entry.summary),
|
|
160
|
+
}
|
|
161
|
+
}
|
|
@@ -69,6 +69,7 @@ export function createCycleScheduler(deps) {
|
|
|
69
69
|
scheduledCycle2Signature,
|
|
70
70
|
scheduledCycle3Signature,
|
|
71
71
|
cycleStateFile,
|
|
72
|
+
onCoreMemoryChanged = async () => {},
|
|
72
73
|
} = deps
|
|
73
74
|
|
|
74
75
|
// ── Cycle health state ────────────────────────────────────────────────────
|
|
@@ -342,6 +343,7 @@ export function createCycleScheduler(deps) {
|
|
|
342
343
|
if (result?.error) { markCycleDone('cycle3', false, result.error); return }
|
|
343
344
|
await setCycleLastRun('cycle3', Date.now())
|
|
344
345
|
markCycleDone('cycle3', true)
|
|
346
|
+
await onCoreMemoryChanged('cycle3')
|
|
345
347
|
},
|
|
346
348
|
}
|
|
347
349
|
if (typeof c3Options?.callLlm !== 'function') {
|
|
@@ -382,6 +384,7 @@ export function createCycleScheduler(deps) {
|
|
|
382
384
|
await setCycleLastRun('cycle2_last_error', '')
|
|
383
385
|
log('[cycle2] completed\n')
|
|
384
386
|
markCycleDone('cycle2', true)
|
|
387
|
+
await onCoreMemoryChanged('cycle2')
|
|
385
388
|
} else {
|
|
386
389
|
const err = gateFailed ? 'gate_failed' : (result.error || 'unknown error')
|
|
387
390
|
await setCycleLastRun('cycle2_last_error', err)
|
|
@@ -566,6 +569,7 @@ export function createCycleScheduler(deps) {
|
|
|
566
569
|
if (result.error) { markCycleDone('cycle3', false, result.error); return }
|
|
567
570
|
await setCycleLastRun('cycle3', Date.now())
|
|
568
571
|
markCycleDone('cycle3', true)
|
|
572
|
+
await onCoreMemoryChanged('cycle3')
|
|
569
573
|
},
|
|
570
574
|
requestCycle3Review,
|
|
571
575
|
periodicCycle1Config,
|
|
@@ -62,6 +62,7 @@ export function createMemoryActionHandlers({
|
|
|
62
62
|
cwdFromTranscriptPath,
|
|
63
63
|
addCoreImpl = addCore,
|
|
64
64
|
editCoreImpl = editCore,
|
|
65
|
+
refreshCoreMemoryFile = async () => {},
|
|
65
66
|
}) {
|
|
66
67
|
const DATA_DIR = dataDir
|
|
67
68
|
|
|
@@ -645,6 +646,7 @@ export function createMemoryActionHandlers({
|
|
|
645
646
|
}
|
|
646
647
|
if (op === 'promote') {
|
|
647
648
|
const entry = await promoteCoreCandidate(coreDataDir, args.id, { ...args, scope })
|
|
649
|
+
await refreshCoreMemoryFile('core-promote')
|
|
648
650
|
await queueCoreReview('core-promote')
|
|
649
651
|
const mergeNote = entry.merged_with ? ` (merged into core id=${entry.merged_with}, sim=${entry.sim})` : ''
|
|
650
652
|
return { text: `core promoted candidate id=${args.id} → core id=${entry.id}${mergeNote}: ${entry.element}` }
|
|
@@ -724,16 +726,19 @@ export function createMemoryActionHandlers({
|
|
|
724
726
|
}
|
|
725
727
|
if (op === 'add') {
|
|
726
728
|
const entry = await addCoreImpl(coreDataDir, args, projectId)
|
|
729
|
+
await refreshCoreMemoryFile('core-add')
|
|
727
730
|
await queueCoreReview('core-add')
|
|
728
731
|
return { text: `core added (id=${entry.id}): ${entry.element} — ${entry.summary.slice(0, 200)}` }
|
|
729
732
|
}
|
|
730
733
|
if (op === 'edit') {
|
|
731
734
|
const entry = await editCoreImpl(coreDataDir, args.id, args)
|
|
735
|
+
await refreshCoreMemoryFile('core-edit')
|
|
732
736
|
await queueCoreReview('core-edit')
|
|
733
737
|
return { text: `core edited (id=${entry.id}): ${entry.element} — ${entry.summary.slice(0, 200)}` }
|
|
734
738
|
}
|
|
735
739
|
if (op === 'delete') {
|
|
736
740
|
const removed = await deleteCore(coreDataDir, args.id)
|
|
741
|
+
await refreshCoreMemoryFile('core-delete')
|
|
737
742
|
return { text: `core deleted (id=${removed.id}): ${removed.element}` }
|
|
738
743
|
}
|
|
739
744
|
} catch (e) {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { discoverPluginMcp } from './plugin-mcp.mjs';
|
|
2
2
|
import { featureEnvOverride, memoryToolsEnabled } from './config-helpers.mjs';
|
|
3
|
+
import { readSessionCoreMemoryPayload } from '../runtime/memory/lib/core-memory-file.mjs';
|
|
3
4
|
|
|
4
5
|
// cwd-plugins.mjs — cwd resolution/apply + plugins-status + core-memory context,
|
|
5
6
|
// extracted from mixdog-session-runtime.mjs. Dependency-injected factory that
|
|
@@ -244,33 +245,27 @@ export function createCwdPlugins({
|
|
|
244
245
|
bootProfile('core-memory:disabled');
|
|
245
246
|
return '';
|
|
246
247
|
}
|
|
247
|
-
// Explicit opt-out (MIXDOG_BOOT_CORE_MEMORY=0/false/no/off) skips
|
|
248
|
-
//
|
|
249
|
-
// memory service on first use.
|
|
248
|
+
// Explicit opt-out (MIXDOG_BOOT_CORE_MEMORY=0/false/no/off) skips this
|
|
249
|
+
// file-backed prompt block. Recall and memory tools remain available.
|
|
250
250
|
const bootFlag = String(process.env.MIXDOG_BOOT_CORE_MEMORY ?? '').trim().toLowerCase();
|
|
251
251
|
if (bootFlag === '0' || bootFlag === 'false' || bootFlag === 'no' || bootFlag === 'off') {
|
|
252
252
|
bootProfile('core-memory:skipped');
|
|
253
253
|
return '';
|
|
254
254
|
}
|
|
255
255
|
const startedAt = performance.now();
|
|
256
|
-
let timer = null;
|
|
257
|
-
const timeout = new Promise((resolveTimeout) => {
|
|
258
|
-
timer = setTimeout(() => resolveTimeout(''), 2000);
|
|
259
|
-
timer.unref?.();
|
|
260
|
-
});
|
|
261
256
|
try {
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
257
|
+
// The prompt path never starts or waits for PG/embedding/IPC. Memory
|
|
258
|
+
// runtime maintains this atomic snapshot independently.
|
|
259
|
+
const dataDir = process.env.MIXDOG_DATA_DIR || cfgMod.getPluginData?.() || STANDALONE_DATA_DIR;
|
|
260
|
+
const payload = readSessionCoreMemoryPayload(dataDir, getCurrentCwd());
|
|
261
|
+
if (!payload) {
|
|
262
|
+
bootProfile('core-memory:file-missing');
|
|
263
|
+
return '';
|
|
264
|
+
}
|
|
265
|
+
return formatCoreMemoryLines(payload);
|
|
270
266
|
} catch {
|
|
271
267
|
return '';
|
|
272
268
|
} finally {
|
|
273
|
-
if (timer) clearTimeout(timer);
|
|
274
269
|
bootProfile('core-memory:done', { ms: (performance.now() - startedAt).toFixed(1) });
|
|
275
270
|
}
|
|
276
271
|
}
|
|
@@ -55,6 +55,7 @@ export function createChannelTransport({
|
|
|
55
55
|
remoteStatePath = null,
|
|
56
56
|
remoteIntentPath = null,
|
|
57
57
|
onClientRegistered = null,
|
|
58
|
+
onRemoteOwnerStateChange = null,
|
|
58
59
|
agentBroker = null,
|
|
59
60
|
} = {}) {
|
|
60
61
|
if (typeof handleCall !== 'function') throw new Error('handleCall is required');
|
|
@@ -243,7 +244,6 @@ export function createChannelTransport({
|
|
|
243
244
|
}
|
|
244
245
|
|
|
245
246
|
function publishRemoteOwnerState() {
|
|
246
|
-
if (!resolvedRemoteStatePath) return;
|
|
247
247
|
const owner = pointerToken ? clients.get(pointerToken) : null;
|
|
248
248
|
const sessionId = String(owner?.remoteSessionId || '');
|
|
249
249
|
const state = {
|
|
@@ -263,6 +263,11 @@ export function createChannelTransport({
|
|
|
263
263
|
]);
|
|
264
264
|
if (signature === remoteStateSignature) return;
|
|
265
265
|
remoteStateSignature = signature;
|
|
266
|
+
if (typeof onRemoteOwnerStateChange === 'function') {
|
|
267
|
+
try { onRemoteOwnerStateChange(state); }
|
|
268
|
+
catch (err) { log(`remote owner state listener failed: ${err?.message || err}`); }
|
|
269
|
+
}
|
|
270
|
+
if (!resolvedRemoteStatePath) return;
|
|
266
271
|
try {
|
|
267
272
|
writeJsonAtomicSync(resolvedRemoteStatePath, state, { compact: true });
|
|
268
273
|
} catch (err) {
|
|
@@ -243,6 +243,7 @@ let sessionRuntimePool = null;
|
|
|
243
243
|
let localSessionBridge = null;
|
|
244
244
|
let memoryRuntime = null;
|
|
245
245
|
let agentDispatchBroker = null;
|
|
246
|
+
let remoteOwnerState = { enabled: false, sessionId: null };
|
|
246
247
|
let shuttingDown = false;
|
|
247
248
|
let shutdownRecheckTimer = null;
|
|
248
249
|
let replacementRequested = null;
|
|
@@ -250,6 +251,27 @@ const eventLoopDelay = monitorEventLoopDelay({ resolution: 20 });
|
|
|
250
251
|
eventLoopDelay.enable();
|
|
251
252
|
let eventLoopLagTimer = null;
|
|
252
253
|
|
|
254
|
+
function startMemoryRuntimeEarly() {
|
|
255
|
+
// Start the isolated memory process immediately after singleton ownership is
|
|
256
|
+
// established. Do not await it: daemon front-door readiness remains
|
|
257
|
+
// independent while PG/embedding cold-start overlaps all other boot work.
|
|
258
|
+
if (process.env.MIXDOG_DAEMON_SKIP_MEMORY === '1') {
|
|
259
|
+
log('memory runtime skipped (MIXDOG_DAEMON_SKIP_MEMORY=1)');
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
if (memoryRuntime) return;
|
|
263
|
+
try {
|
|
264
|
+
memoryRuntime = getStandaloneMemoryRuntime({
|
|
265
|
+
entry: MEMORY_ENTRY,
|
|
266
|
+
dataDir: DATA_DIR,
|
|
267
|
+
cwd: CWD,
|
|
268
|
+
});
|
|
269
|
+
void memoryRuntime.init()
|
|
270
|
+
.then(() => log('memory runtime ready in isolated process'))
|
|
271
|
+
.catch((e) => log(`memory.start failed (non-fatal): ${e?.message || e}`));
|
|
272
|
+
} catch (e) { log(`memory.start setup failed (non-fatal): ${e?.message || e}`); }
|
|
273
|
+
}
|
|
274
|
+
|
|
253
275
|
function eventLoopStatus() {
|
|
254
276
|
const milliseconds = (value) => Number.isFinite(value) ? Math.round(value / 1e6) : 0;
|
|
255
277
|
return {
|
|
@@ -387,6 +409,7 @@ async function main() {
|
|
|
387
409
|
process.exit(0);
|
|
388
410
|
}
|
|
389
411
|
process.on('exit', () => { try { releaseSingletonOwner(OWNER_PATH, process.pid); } catch {} });
|
|
412
|
+
startMemoryRuntimeEarly();
|
|
390
413
|
agentDispatchBroker = createAgentDispatchBroker({
|
|
391
414
|
loadConfig: loadAgentConfig,
|
|
392
415
|
initProviders,
|
|
@@ -458,6 +481,17 @@ async function main() {
|
|
|
458
481
|
onClientsEmpty: () => { maybeSelfShutdown('no live channel clients'); },
|
|
459
482
|
// First channels client in: bring the channels runtime up (see startChannels).
|
|
460
483
|
onClientRegistered: () => { startChannels(); },
|
|
484
|
+
onRemoteOwnerStateChange: (state) => {
|
|
485
|
+
remoteOwnerState = {
|
|
486
|
+
enabled: state?.enabled === true,
|
|
487
|
+
sessionId: state?.enabled === true && state?.sessionId
|
|
488
|
+
? String(state.sessionId)
|
|
489
|
+
: null,
|
|
490
|
+
};
|
|
491
|
+
const frame = { type: 'remote-owner-state', ...remoteOwnerState };
|
|
492
|
+
localSessionBridge?.publish(frame);
|
|
493
|
+
sessionTransport?.broadcast(frame);
|
|
494
|
+
},
|
|
461
495
|
});
|
|
462
496
|
setChannelNotifySink((method, params) => transport.notify(method, params));
|
|
463
497
|
const { port, token } = await transport.start();
|
|
@@ -571,6 +605,7 @@ async function main() {
|
|
|
571
605
|
refreshFromStorage: options.refreshFromStorage === true,
|
|
572
606
|
});
|
|
573
607
|
},
|
|
608
|
+
getRemoteOwnerState: () => remoteOwnerState,
|
|
574
609
|
desktopRuntime,
|
|
575
610
|
onFrame: (frame, targetTokens) => {
|
|
576
611
|
localSessionBridge?.publish(frame, targetTokens);
|
|
@@ -681,27 +716,6 @@ async function main() {
|
|
|
681
716
|
// client — see the transport's onClientRegistered hook.
|
|
682
717
|
if (process.env.MIXDOG_DAEMON_SPAWNED_FOR !== 'session') startChannels();
|
|
683
718
|
|
|
684
|
-
// Memory owns a separate process/event loop. Initialization stays
|
|
685
|
-
// asynchronous because DB/embedding startup must not delay the front-door
|
|
686
|
-
// ready handshake. The proxy retains it for this daemon pid and its
|
|
687
|
-
// client-grace lifecycle reaps it only after the daemon is gone.
|
|
688
|
-
// Isolated test roots opt out (MIXDOG_DAEMON_SKIP_MEMORY=1): spinning a
|
|
689
|
-
// throwaway Postgres cluster per test run is pure cost, and a hard-killed
|
|
690
|
-
// daemon would orphan it.
|
|
691
|
-
if (process.env.MIXDOG_DAEMON_SKIP_MEMORY === '1') {
|
|
692
|
-
log('memory runtime skipped (MIXDOG_DAEMON_SKIP_MEMORY=1)');
|
|
693
|
-
return;
|
|
694
|
-
}
|
|
695
|
-
try {
|
|
696
|
-
memoryRuntime = getStandaloneMemoryRuntime({
|
|
697
|
-
entry: MEMORY_ENTRY,
|
|
698
|
-
dataDir: DATA_DIR,
|
|
699
|
-
cwd: CWD,
|
|
700
|
-
});
|
|
701
|
-
void memoryRuntime.init()
|
|
702
|
-
.then(() => log('memory runtime ready in isolated process'))
|
|
703
|
-
.catch((e) => log(`memory.start failed (non-fatal): ${e?.message || e}`));
|
|
704
|
-
} catch (e) { log(`memory.start setup failed (non-fatal): ${e?.message || e}`); }
|
|
705
719
|
}
|
|
706
720
|
|
|
707
721
|
process.on('SIGTERM', () => { void shutdown('SIGTERM'); });
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
SESSION_READ_ACTIONS,
|
|
25
25
|
SESSION_READ_ACTION_SET,
|
|
26
26
|
} from './session-protocol.mjs';
|
|
27
|
+
import { readSingletonOwner } from '../runtime/shared/singleton-owner.mjs';
|
|
27
28
|
|
|
28
29
|
function runtimeRoot() {
|
|
29
30
|
return process.env.MIXDOG_RUNTIME_ROOT
|
|
@@ -35,6 +36,13 @@ export function sessionDiscoveryPath() {
|
|
|
35
36
|
return path.join(runtimeRoot(), 'daemon.json');
|
|
36
37
|
}
|
|
37
38
|
|
|
39
|
+
function daemonOwnerPath() {
|
|
40
|
+
const dataDir = process.env.MIXDOG_DATA_DIR
|
|
41
|
+
? path.resolve(process.env.MIXDOG_DATA_DIR)
|
|
42
|
+
: path.join(process.env.MIXDOG_HOME || path.join(os.homedir(), '.mixdog'), 'data');
|
|
43
|
+
return path.join(dataDir, 'daemon-owner.json');
|
|
44
|
+
}
|
|
45
|
+
|
|
38
46
|
function daemonEntry() {
|
|
39
47
|
// ONE daemon: the channels/memory host also owns the session pool, so both
|
|
40
48
|
// spawn paths converge on the same singleton process.
|
|
@@ -61,13 +69,14 @@ const URGENT_CALLS = new Set([
|
|
|
61
69
|
'desktop.control',
|
|
62
70
|
'desktop.unsubscribe',
|
|
63
71
|
]);
|
|
64
|
-
const EVENT_STREAM_RECONNECT_BASE_MS =
|
|
65
|
-
const EVENT_STREAM_RECONNECT_MAX_MS =
|
|
66
|
-
const EVENT_STREAM_HEALTH_GRACE_MS = 750;
|
|
72
|
+
const EVENT_STREAM_RECONNECT_BASE_MS = 1_000;
|
|
73
|
+
const EVENT_STREAM_RECONNECT_MAX_MS = 30_000;
|
|
67
74
|
// Claude Code transport parity: keepalive silence is detected independently
|
|
68
75
|
// from TCP close, and a continuously failing reconnect storm is bounded.
|
|
69
76
|
const EVENT_STREAM_LIVENESS_TIMEOUT_MS = 45_000;
|
|
70
77
|
const EVENT_STREAM_RECONNECT_BUDGET_MS = 10 * 60_000;
|
|
78
|
+
const DEFAULT_DAEMON_READY_TIMEOUT_MS = 15_000;
|
|
79
|
+
const DAEMON_OWNER_POLL_MS = 50;
|
|
71
80
|
// A daemon that restarted needs a moment to rebind its port, so one immediate
|
|
72
81
|
// re-attach often lands in the same gap the first call died in. Mirror the
|
|
73
82
|
// event-stream policy above with a short bounded ladder; `callId` keeps a
|
|
@@ -127,17 +136,20 @@ export async function probeSessionHealth({ port, token, timeoutMs = 800 } = {})
|
|
|
127
136
|
} catch { return null; }
|
|
128
137
|
}
|
|
129
138
|
|
|
139
|
+
function isPidAlive(pid) {
|
|
140
|
+
const value = Number(pid);
|
|
141
|
+
if (!Number.isInteger(value) || value <= 0) return false;
|
|
142
|
+
try { process.kill(value, 0); return true; }
|
|
143
|
+
catch (error) { return error?.code === 'EPERM'; }
|
|
144
|
+
}
|
|
145
|
+
|
|
130
146
|
export function readSessionDiscovery(discoveryPath = sessionDiscoveryPath()) {
|
|
131
|
-
const pidAlive = (pid) => {
|
|
132
|
-
try { process.kill(Number(pid), 0); return true; }
|
|
133
|
-
catch (error) { return error?.code === 'EPERM'; }
|
|
134
|
-
};
|
|
135
147
|
const readUnified = (candidate) => {
|
|
136
148
|
const parsed = JSON.parse(readFileSync(candidate, 'utf8'));
|
|
137
149
|
const endpoint = parsed?.endpoints?.session;
|
|
138
150
|
const channel = parsed?.endpoints?.channel;
|
|
139
151
|
const pid = parsed?.pid;
|
|
140
|
-
if (!endpoint?.port || !endpoint?.token || !pid || !
|
|
152
|
+
if (!endpoint?.port || !endpoint?.token || !pid || !isPidAlive(pid)) return null;
|
|
141
153
|
return {
|
|
142
154
|
...endpoint,
|
|
143
155
|
pid,
|
|
@@ -229,19 +241,29 @@ async function replaceLowerDaemon(discovery, initialHealth, { log }) {
|
|
|
229
241
|
/** Fork one daemon candidate DETACHED (it outlives this client — machine
|
|
230
242
|
* global) and resolve when it reports ready OR exits (race loss/crash); the
|
|
231
243
|
* caller then re-reads discovery and attaches to whoever won. */
|
|
232
|
-
function spawnDaemonCandidate({ cwd, log }) {
|
|
244
|
+
function spawnDaemonCandidate({ cwd, log, timeoutMs = 30_000 }) {
|
|
233
245
|
return new Promise((resolve) => {
|
|
234
246
|
let settled = false;
|
|
235
|
-
|
|
247
|
+
let timer = null;
|
|
248
|
+
const done = () => {
|
|
249
|
+
if (settled) return;
|
|
250
|
+
settled = true;
|
|
251
|
+
if (timer) clearTimeout(timer);
|
|
252
|
+
resolve();
|
|
253
|
+
};
|
|
236
254
|
let child;
|
|
237
255
|
try {
|
|
238
256
|
child = fork(daemonEntry(), [], {
|
|
239
257
|
cwd,
|
|
240
258
|
stdio: ['ignore', 'ignore', 'pipe', 'ipc'],
|
|
241
|
-
|
|
259
|
+
// The machine-global daemon must not share Electron's Windows process
|
|
260
|
+
// lifetime. A GPU/Crashpad failure in the desktop must leave this Node
|
|
261
|
+
// host and every live session untouched.
|
|
262
|
+
detached: true,
|
|
242
263
|
windowsHide: true,
|
|
243
264
|
env: {
|
|
244
265
|
...process.env,
|
|
266
|
+
ELECTRON_RUN_AS_NODE: '1',
|
|
245
267
|
MIXDOG_DAEMON_HOST: '1',
|
|
246
268
|
// Session-only spawn: the daemon stays dormant on the channels side
|
|
247
269
|
// until a channels client registers.
|
|
@@ -269,14 +291,32 @@ function spawnDaemonCandidate({ cwd, log }) {
|
|
|
269
291
|
});
|
|
270
292
|
child.once('exit', done);
|
|
271
293
|
child.once('error', (err) => { log(`daemon spawn error: ${err?.message || err}`); done(); });
|
|
272
|
-
|
|
294
|
+
timer = setTimeout(done, Math.max(1, timeoutMs));
|
|
273
295
|
timer.unref?.();
|
|
274
296
|
});
|
|
275
297
|
}
|
|
276
298
|
|
|
277
299
|
/** Spawn-or-attach discovery for the machine-global daemon. */
|
|
278
|
-
export async function ensureDaemon({
|
|
279
|
-
|
|
300
|
+
export async function ensureDaemon({
|
|
301
|
+
cwd = process.cwd(),
|
|
302
|
+
log = () => {},
|
|
303
|
+
attempts = 5,
|
|
304
|
+
readyTimeoutMs = null,
|
|
305
|
+
} = {}) {
|
|
306
|
+
const configuredTimeoutMs = Number(process.env.MIXDOG_DAEMON_READY_TIMEOUT_MS);
|
|
307
|
+
const timeoutMs = Math.max(
|
|
308
|
+
1,
|
|
309
|
+
Number.isFinite(Number(readyTimeoutMs)) && Number(readyTimeoutMs) > 0
|
|
310
|
+
? Number(readyTimeoutMs)
|
|
311
|
+
: configuredTimeoutMs > 0
|
|
312
|
+
? configuredTimeoutMs
|
|
313
|
+
: DEFAULT_DAEMON_READY_TIMEOUT_MS,
|
|
314
|
+
);
|
|
315
|
+
const deadline = Date.now() + timeoutMs;
|
|
316
|
+
const maxSpawnAttempts = Math.max(0, Math.floor(Number(attempts) || 0));
|
|
317
|
+
let spawnAttempts = 0;
|
|
318
|
+
let waitingOwnerPid = 0;
|
|
319
|
+
while (Date.now() < deadline) {
|
|
280
320
|
const discovery = readSessionDiscovery();
|
|
281
321
|
if (discovery) {
|
|
282
322
|
const health = await probeSessionHealth({ port: discovery.port, token: discovery.token });
|
|
@@ -294,8 +334,27 @@ export async function ensureDaemon({ cwd = process.cwd(), log = () => {}, attemp
|
|
|
294
334
|
throw err;
|
|
295
335
|
}
|
|
296
336
|
}
|
|
297
|
-
|
|
298
|
-
|
|
337
|
+
// A concurrent launcher can win the owner lock before it publishes
|
|
338
|
+
// daemon.json. That is a healthy singleton boot, not a reason to spawn five
|
|
339
|
+
// doomed contenders and report "endpoint unavailable" after ~500 ms.
|
|
340
|
+
const ownerState = readSingletonOwner(daemonOwnerPath());
|
|
341
|
+
const ownerPid = ownerState.alive ? Number(ownerState.owner?.pid) || 0 : 0;
|
|
342
|
+
if (ownerPid) {
|
|
343
|
+
if (ownerPid !== waitingOwnerPid) {
|
|
344
|
+
waitingOwnerPid = ownerPid;
|
|
345
|
+
log(`waiting for daemon owner pid=${ownerPid} to publish its session endpoint`);
|
|
346
|
+
}
|
|
347
|
+
await delay(Math.min(DAEMON_OWNER_POLL_MS, Math.max(1, deadline - Date.now())));
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
waitingOwnerPid = 0;
|
|
351
|
+
if (spawnAttempts >= maxSpawnAttempts) break;
|
|
352
|
+
spawnAttempts += 1;
|
|
353
|
+
await spawnDaemonCandidate({
|
|
354
|
+
cwd,
|
|
355
|
+
log,
|
|
356
|
+
timeoutMs: Math.max(1, deadline - Date.now()),
|
|
357
|
+
});
|
|
299
358
|
}
|
|
300
359
|
throw new Error('daemon session endpoint is unavailable');
|
|
301
360
|
}
|
|
@@ -314,7 +373,6 @@ export async function attachSession({
|
|
|
314
373
|
onStreamReconnect = () => {},
|
|
315
374
|
streamReconnectBaseMs = EVENT_STREAM_RECONNECT_BASE_MS,
|
|
316
375
|
streamReconnectMaxMs = EVENT_STREAM_RECONNECT_MAX_MS,
|
|
317
|
-
streamReconnectHealthGraceMs = EVENT_STREAM_HEALTH_GRACE_MS,
|
|
318
376
|
streamLivenessTimeoutMs = EVENT_STREAM_LIVENESS_TIMEOUT_MS,
|
|
319
377
|
streamReconnectBudgetMs = EVENT_STREAM_RECONNECT_BUDGET_MS,
|
|
320
378
|
log = () => {},
|
|
@@ -364,10 +422,6 @@ export async function attachSession({
|
|
|
364
422
|
reconnectBaseMs,
|
|
365
423
|
Number(streamReconnectMaxMs) || EVENT_STREAM_RECONNECT_MAX_MS,
|
|
366
424
|
);
|
|
367
|
-
const healthGraceMs = Math.max(
|
|
368
|
-
reconnectBaseMs,
|
|
369
|
-
Number(streamReconnectHealthGraceMs) || EVENT_STREAM_HEALTH_GRACE_MS,
|
|
370
|
-
);
|
|
371
425
|
const livenessMs = Math.max(
|
|
372
426
|
1,
|
|
373
427
|
Number(streamLivenessTimeoutMs) || EVENT_STREAM_LIVENESS_TIMEOUT_MS,
|
|
@@ -426,15 +480,40 @@ export async function attachSession({
|
|
|
426
480
|
signalFatal(`${lastLossReason}; reconnect budget exhausted after ${downtimeMs}ms`);
|
|
427
481
|
return;
|
|
428
482
|
}
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
483
|
+
const current = readSessionDiscovery();
|
|
484
|
+
const replaced = current
|
|
485
|
+
&& (
|
|
486
|
+
Number(current.pid) !== Number(discovery.pid)
|
|
487
|
+
|| Number(current.port) !== Number(port)
|
|
488
|
+
|| String(current.token || '') !== String(serverToken)
|
|
489
|
+
);
|
|
490
|
+
if (replaced) {
|
|
491
|
+
const health = await probeSessionHealth({
|
|
492
|
+
port: current.port,
|
|
493
|
+
token: current.token,
|
|
494
|
+
timeoutMs: 800,
|
|
495
|
+
});
|
|
496
|
+
if (health && Number(health.pid) === Number(current.pid)) {
|
|
497
|
+
signalFatal(
|
|
498
|
+
`${lastLossReason}; daemon replaced`
|
|
499
|
+
+ ` oldPid=${Number(discovery.pid)} oldPort=${Number(port)}`
|
|
500
|
+
+ ` newPid=${Number(current.pid)} newPort=${Number(current.port)}`,
|
|
501
|
+
);
|
|
433
502
|
return;
|
|
434
503
|
}
|
|
435
504
|
}
|
|
505
|
+
if (!isPidAlive(discovery.pid)) {
|
|
506
|
+
signalFatal(
|
|
507
|
+
`${lastLossReason}; daemon exited`
|
|
508
|
+
+ ` pid=${Number(discovery.pid)} port=${Number(port)}`,
|
|
509
|
+
);
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
openStream();
|
|
513
|
+
})().catch((error) => {
|
|
514
|
+
log(`session event stream recovery probe failed: ${error?.message || error}`);
|
|
436
515
|
openStream();
|
|
437
|
-
})
|
|
516
|
+
});
|
|
438
517
|
}, delayMs);
|
|
439
518
|
reconnectTimer.unref?.();
|
|
440
519
|
}
|
|
@@ -98,6 +98,7 @@ export function createSessionService({
|
|
|
98
98
|
sessionExists = null,
|
|
99
99
|
readStoredSession = null,
|
|
100
100
|
listSessions = null,
|
|
101
|
+
getRemoteOwnerState = null,
|
|
101
102
|
desktopRuntime = null,
|
|
102
103
|
publishIntervalMs = 16,
|
|
103
104
|
onFrame = () => {},
|
|
@@ -721,7 +722,13 @@ export function createSessionService({
|
|
|
721
722
|
throw new Error('session catalog is unavailable');
|
|
722
723
|
}
|
|
723
724
|
const sessions = await listSessions(options || {});
|
|
724
|
-
|
|
725
|
+
const remoteOwner = typeof getRemoteOwnerState === 'function'
|
|
726
|
+
? await getRemoteOwnerState()
|
|
727
|
+
: null;
|
|
728
|
+
return {
|
|
729
|
+
sessions: sanitizeForWire(Array.isArray(sessions) ? sessions : []),
|
|
730
|
+
remoteOwner: sanitizeForWire(remoteOwner) ?? null,
|
|
731
|
+
};
|
|
725
732
|
}
|
|
726
733
|
|
|
727
734
|
async function loadProjectStore() {
|
|
@@ -177,9 +177,7 @@ export function resolveAnchorScrollOffset({ anchor, items, curPrefix, totalRows,
|
|
|
177
177
|
if (list[i] && list[i].id === anchor.id) { idx = i; break; }
|
|
178
178
|
}
|
|
179
179
|
if (idx < 0 || idx > curPrefix.length - 2) return null;
|
|
180
|
-
const
|
|
181
|
-
const clampedOffset = Math.max(0, Math.min(Number(anchor.offset) || 0, itemHeight));
|
|
182
|
-
const anchorRowCur = transcriptRowAt(curPrefix, idx) + clampedOffset;
|
|
180
|
+
const anchorRowCur = transcriptRowAt(curPrefix, idx) + (Number(anchor.offset) || 0);
|
|
183
181
|
return Math.max(0, Math.min(maxRows, totalRows - viewRows - anchorRowCur));
|
|
184
182
|
}
|
|
185
183
|
|
|
@@ -2,15 +2,16 @@ export const PROMPT_ESCAPE_CLEAR_WINDOW_MS = 800;
|
|
|
2
2
|
export const PROMPT_ESCAPE_HINT_TIMEOUT_MS = 1000;
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* Claude Code-compatible chat Escape priority after overlays
|
|
6
|
-
*
|
|
7
|
-
* active turn -> interrupt immediately,
|
|
8
|
-
* messages -> restore for editing, idle
|
|
9
|
-
*
|
|
10
|
-
* fallback.
|
|
5
|
+
* Claude Code-compatible chat Escape priority after overlays have already had
|
|
6
|
+
* a chance to consume the key:
|
|
7
|
+
* active turn -> interrupt immediately, selected draft -> collapse selection,
|
|
8
|
+
* then (while idle) queued editable messages -> restore for editing, idle
|
|
9
|
+
* non-empty draft -> double-press clear, idle empty draft with history ->
|
|
10
|
+
* double-press message selector, otherwise fallback.
|
|
11
11
|
*/
|
|
12
12
|
export function classifyPromptEscape({
|
|
13
13
|
interruptActive = false,
|
|
14
|
+
hasSelection = false,
|
|
14
15
|
hasQueuedMessages = false,
|
|
15
16
|
hasMessages = false,
|
|
16
17
|
value = '',
|
|
@@ -18,6 +19,7 @@ export function classifyPromptEscape({
|
|
|
18
19
|
now = Date.now(),
|
|
19
20
|
} = {}) {
|
|
20
21
|
if (interruptActive) return { action: 'interrupt', nextClearPressAt: 0 };
|
|
22
|
+
if (hasSelection) return { action: 'collapse-selection', nextClearPressAt: 0 };
|
|
21
23
|
if (hasQueuedMessages) return { action: 'restore-queue', nextClearPressAt: 0 };
|
|
22
24
|
|
|
23
25
|
const current = Number(now);
|
package/src/tui/dist/index.mjs
CHANGED
|
@@ -6113,6 +6113,7 @@ var PROMPT_ESCAPE_CLEAR_WINDOW_MS = 800;
|
|
|
6113
6113
|
var PROMPT_ESCAPE_HINT_TIMEOUT_MS = 1e3;
|
|
6114
6114
|
function classifyPromptEscape({
|
|
6115
6115
|
interruptActive = false,
|
|
6116
|
+
hasSelection = false,
|
|
6116
6117
|
hasQueuedMessages = false,
|
|
6117
6118
|
hasMessages = false,
|
|
6118
6119
|
value = "",
|
|
@@ -6120,6 +6121,7 @@ function classifyPromptEscape({
|
|
|
6120
6121
|
now = Date.now()
|
|
6121
6122
|
} = {}) {
|
|
6122
6123
|
if (interruptActive) return { action: "interrupt", nextClearPressAt: 0 };
|
|
6124
|
+
if (hasSelection) return { action: "collapse-selection", nextClearPressAt: 0 };
|
|
6123
6125
|
if (hasQueuedMessages) return { action: "restore-queue", nextClearPressAt: 0 };
|
|
6124
6126
|
const current = Number(now);
|
|
6125
6127
|
const previous = Number(lastClearPressAt);
|
|
@@ -11697,9 +11699,7 @@ function resolveAnchorScrollOffset({ anchor, items, curPrefix, totalRows, viewRo
|
|
|
11697
11699
|
}
|
|
11698
11700
|
}
|
|
11699
11701
|
if (idx < 0 || idx > curPrefix.length - 2) return null;
|
|
11700
|
-
const
|
|
11701
|
-
const clampedOffset = Math.max(0, Math.min(Number(anchor.offset) || 0, itemHeight));
|
|
11702
|
-
const anchorRowCur = transcriptRowAt(curPrefix, idx) + clampedOffset;
|
|
11702
|
+
const anchorRowCur = transcriptRowAt(curPrefix, idx) + (Number(anchor.offset) || 0);
|
|
11703
11703
|
return Math.max(0, Math.min(maxRows, totalRows - viewRows - anchorRowCur));
|
|
11704
11704
|
}
|
|
11705
11705
|
function fnv1a32(str) {
|