@sapiom/harness 0.1.6 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +105 -0
- package/dist/cli/args.d.ts +16 -0
- package/dist/cli/args.d.ts.map +1 -0
- package/dist/cli/args.js +80 -0
- package/dist/cli/args.js.map +1 -0
- package/dist/cli/bin.js +12 -65
- package/dist/cli/bin.js.map +1 -1
- package/dist/cli/settings.d.ts.map +1 -1
- package/dist/cli/settings.js +33 -1
- package/dist/cli/settings.js.map +1 -1
- package/dist/core/adapters/claude-code.d.ts +60 -2
- package/dist/core/adapters/claude-code.d.ts.map +1 -1
- package/dist/core/adapters/claude-code.js +119 -32
- package/dist/core/adapters/claude-code.js.map +1 -1
- package/dist/core/adapters/codex.d.ts +21 -2
- package/dist/core/adapters/codex.d.ts.map +1 -1
- package/dist/core/adapters/codex.js +53 -2
- package/dist/core/adapters/codex.js.map +1 -1
- package/dist/core/adapters/detect.d.ts +9 -0
- package/dist/core/adapters/detect.d.ts.map +1 -1
- package/dist/core/adapters/detect.js +9 -0
- package/dist/core/adapters/detect.js.map +1 -1
- package/dist/core/canvas-body.d.ts +28 -28
- package/dist/core/canvas-body.d.ts.map +1 -1
- package/dist/core/canvas-body.js +117 -55
- package/dist/core/canvas-body.js.map +1 -1
- package/dist/core/canvas-derive.d.ts +43 -0
- package/dist/core/canvas-derive.d.ts.map +1 -0
- package/dist/core/canvas-derive.js +95 -0
- package/dist/core/canvas-derive.js.map +1 -0
- package/dist/core/canvas-enrichment.d.ts +52 -127
- package/dist/core/canvas-enrichment.d.ts.map +1 -1
- package/dist/core/canvas-enrichment.js +17 -178
- package/dist/core/canvas-enrichment.js.map +1 -1
- package/dist/core/canvas-graph.d.ts +40 -1
- package/dist/core/canvas-graph.d.ts.map +1 -1
- package/dist/core/canvas-graph.js +71 -9
- package/dist/core/canvas-graph.js.map +1 -1
- package/dist/core/canvas-interconnections.d.ts +26 -8
- package/dist/core/canvas-interconnections.d.ts.map +1 -1
- package/dist/core/canvas-interconnections.js +132 -24
- package/dist/core/canvas-interconnections.js.map +1 -1
- package/dist/core/canvas-manifest-check.d.ts.map +1 -1
- package/dist/core/canvas-manifest-check.js +15 -2
- package/dist/core/canvas-manifest-check.js.map +1 -1
- package/dist/core/canvas-render.d.ts +8 -15
- package/dist/core/canvas-render.d.ts.map +1 -1
- package/dist/core/canvas-render.js +29 -41
- package/dist/core/canvas-render.js.map +1 -1
- package/dist/core/canvas-run-state.d.ts +41 -0
- package/dist/core/canvas-run-state.d.ts.map +1 -1
- package/dist/core/canvas-run-state.js +136 -0
- package/dist/core/canvas-run-state.js.map +1 -1
- package/dist/core/canvas-svg.d.ts +9 -4
- package/dist/core/canvas-svg.d.ts.map +1 -1
- package/dist/core/canvas-svg.js +84 -13
- package/dist/core/canvas-svg.js.map +1 -1
- package/dist/core/canvas-template.d.ts +9 -10
- package/dist/core/canvas-template.d.ts.map +1 -1
- package/dist/core/canvas-template.js +57 -20
- package/dist/core/canvas-template.js.map +1 -1
- package/dist/core/canvas-watcher.d.ts +22 -1
- package/dist/core/canvas-watcher.d.ts.map +1 -1
- package/dist/core/canvas-watcher.js +111 -30
- package/dist/core/canvas-watcher.js.map +1 -1
- package/dist/core/collector/normalizer.d.ts +8 -0
- package/dist/core/collector/normalizer.d.ts.map +1 -1
- package/dist/core/collector/normalizer.js +8 -0
- package/dist/core/collector/normalizer.js.map +1 -1
- package/dist/core/collector/store.d.ts +75 -1
- package/dist/core/collector/store.d.ts.map +1 -1
- package/dist/core/collector/store.js +334 -2
- package/dist/core/collector/store.js.map +1 -1
- package/dist/core/errors.d.ts +10 -4
- package/dist/core/errors.d.ts.map +1 -1
- package/dist/core/errors.js +11 -5
- package/dist/core/errors.js.map +1 -1
- package/dist/core/example-seed.d.ts +8 -0
- package/dist/core/example-seed.d.ts.map +1 -1
- package/dist/core/example-seed.js +47 -10
- package/dist/core/example-seed.js.map +1 -1
- package/dist/core/inject/system-prompt.d.ts +8 -0
- package/dist/core/inject/system-prompt.d.ts.map +1 -1
- package/dist/core/inject/system-prompt.js +3 -1
- package/dist/core/inject/system-prompt.js.map +1 -1
- package/dist/core/macros.d.ts.map +1 -1
- package/dist/core/macros.js +5 -6
- package/dist/core/macros.js.map +1 -1
- package/dist/core/paths.d.ts +1 -0
- package/dist/core/paths.d.ts.map +1 -1
- package/dist/core/paths.js +1 -0
- package/dist/core/paths.js.map +1 -1
- package/dist/core/record-archive.d.ts +187 -0
- package/dist/core/record-archive.d.ts.map +1 -0
- package/dist/core/record-archive.js +465 -0
- package/dist/core/record-archive.js.map +1 -0
- package/dist/core/rehydration.d.ts +72 -0
- package/dist/core/rehydration.d.ts.map +1 -0
- package/dist/core/rehydration.js +72 -0
- package/dist/core/rehydration.js.map +1 -0
- package/dist/core/resume-brief.d.ts +130 -0
- package/dist/core/resume-brief.d.ts.map +1 -0
- package/dist/core/resume-brief.js +404 -0
- package/dist/core/resume-brief.js.map +1 -0
- package/dist/core/rolling-summary.d.ts +130 -0
- package/dist/core/rolling-summary.d.ts.map +1 -0
- package/dist/core/rolling-summary.js +227 -0
- package/dist/core/rolling-summary.js.map +1 -0
- package/dist/core/session-manager.d.ts +11 -3
- package/dist/core/session-manager.d.ts.map +1 -1
- package/dist/core/session-manager.js +56 -7
- package/dist/core/session-manager.js.map +1 -1
- package/dist/core/session-record.d.ts +165 -0
- package/dist/core/session-record.d.ts.map +1 -0
- package/dist/core/session-record.js +470 -0
- package/dist/core/session-record.js.map +1 -0
- package/dist/core/spawn-target.d.ts +12 -0
- package/dist/core/spawn-target.d.ts.map +1 -0
- package/dist/core/spawn-target.js +187 -0
- package/dist/core/spawn-target.js.map +1 -0
- package/dist/core/task-manager.d.ts.map +1 -1
- package/dist/core/task-manager.js +9 -1
- package/dist/core/task-manager.js.map +1 -1
- package/dist/core/template-catalog.d.ts +48 -0
- package/dist/core/template-catalog.d.ts.map +1 -0
- package/dist/core/template-catalog.js +338 -0
- package/dist/core/template-catalog.js.map +1 -0
- package/dist/core/workflow-registry.d.ts.map +1 -1
- package/dist/core/workflow-registry.js +2 -2
- package/dist/core/workflow-registry.js.map +1 -1
- package/dist/core/workspace-watcher.d.ts.map +1 -1
- package/dist/core/workspace-watcher.js +4 -3
- package/dist/core/workspace-watcher.js.map +1 -1
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +21 -0
- package/dist/index.js.map +1 -1
- package/dist/profiles/default.d.ts.map +1 -1
- package/dist/profiles/default.js +9 -14
- package/dist/profiles/default.js.map +1 -1
- package/dist/server/canvas.d.ts.map +1 -1
- package/dist/server/canvas.js +7 -7
- package/dist/server/canvas.js.map +1 -1
- package/dist/server/fs.d.ts.map +1 -1
- package/dist/server/fs.js +17 -1
- package/dist/server/fs.js.map +1 -1
- package/dist/server/index.d.ts +12 -3
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +321 -50
- package/dist/server/index.js.map +1 -1
- package/dist/server/ingest.d.ts +11 -0
- package/dist/server/ingest.d.ts.map +1 -1
- package/dist/server/ingest.js +1 -0
- package/dist/server/ingest.js.map +1 -1
- package/dist/server/macros.d.ts +4 -6
- package/dist/server/macros.d.ts.map +1 -1
- package/dist/server/macros.js.map +1 -1
- package/dist/server/rest.d.ts +18 -6
- package/dist/server/rest.d.ts.map +1 -1
- package/dist/server/rest.js +185 -48
- package/dist/server/rest.js.map +1 -1
- package/dist/server/templates.d.ts +24 -0
- package/dist/server/templates.d.ts.map +1 -0
- package/dist/server/templates.js +54 -0
- package/dist/server/templates.js.map +1 -0
- package/dist/shared/types.d.ts +477 -29
- package/dist/shared/types.d.ts.map +1 -1
- package/dist/shared/types.js +19 -11
- package/dist/shared/types.js.map +1 -1
- package/dist/web/assets/index-SYpugKQ1.css +32 -0
- package/dist/web/assets/index-vYYd44hZ.js +446 -0
- package/dist/web/index.html +2 -2
- package/package.json +5 -4
- package/dist/core/canvas-enrich.d.ts +0 -115
- package/dist/core/canvas-enrich.d.ts.map +0 -1
- package/dist/core/canvas-enrich.js +0 -186
- package/dist/core/canvas-enrich.js.map +0 -1
- package/dist/profiles/canvas-guidelines.d.ts +0 -20
- package/dist/profiles/canvas-guidelines.d.ts.map +0 -1
- package/dist/profiles/canvas-guidelines.js +0 -48
- package/dist/profiles/canvas-guidelines.js.map +0 -1
- package/dist/web/assets/index-DCtyI4VW.css +0 -32
- package/dist/web/assets/index-WSw5RvYR.js +0 -445
- package/dist/web/welcome-hero-dark.png +0 -0
- package/dist/web/welcome-hero-light.png +0 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rolling session summary — the thing that makes rehydration good rather than
|
|
3
|
+
* merely possible.
|
|
4
|
+
*
|
|
5
|
+
* A resume brief built from the last N turns alone tells a fresh session what
|
|
6
|
+
* just happened; it cannot tell it what the session was *for*. So, opt-in, we
|
|
7
|
+
* periodically fold the whole record down to a ≤500-word summary and cache it
|
|
8
|
+
* at `<generated>/<harnessSessionId>/summary.md`. The brief
|
|
9
|
+
* (core/resume-brief.ts) picks it up when it's there and degrades cleanly to
|
|
10
|
+
* last-N-turns when it isn't — which is the default, since this is
|
|
11
|
+
* setting-gated (`HarnessSettings.rollingSummary`).
|
|
12
|
+
*
|
|
13
|
+
* NEVER BLOCKS A TURN. Everything here is fire-and-forget off the ingest path:
|
|
14
|
+
* `noteEvent` returns synchronously, the fold runs as a headless one-shot
|
|
15
|
+
* `TaskManager` job (a separate process, a cheap model, `--max-turns 1` — the
|
|
16
|
+
* bounded shape `HarnessAdapter.launchTask` exists for), and every failure
|
|
17
|
+
* mode short-circuits to "no summary". A user's session must never be slower,
|
|
18
|
+
* or fail, because a summary didn't happen.
|
|
19
|
+
*
|
|
20
|
+
* The task does not write the file itself. It is asked for the summary text
|
|
21
|
+
* and nothing else; we write `summary.md` from its `resultText` when it
|
|
22
|
+
* completes. Two reasons: the target lives under `<generated>/`, outside the
|
|
23
|
+
* session's cwd — which codex's `workspace-write` sandbox forbids and
|
|
24
|
+
* claude-code's `acceptEdits` shouldn't be asked to reach — and a task that
|
|
25
|
+
* writes its own output can half-write it, where a task that returns text
|
|
26
|
+
* either produces a whole summary or none.
|
|
27
|
+
*
|
|
28
|
+
* WHERE IT DOESN'T WORK: `launchTask` is optional on the adapter contract and
|
|
29
|
+
* codex has no non-interactive mode wired for it, so a codex session simply
|
|
30
|
+
* never produces a summary and its briefs are last-N-turns. That is a real
|
|
31
|
+
* gap, stated rather than papered over; the brief is honest either way.
|
|
32
|
+
*/
|
|
33
|
+
import type { AnalyticsEvent, BackgroundTask, HarnessKind, SessionRecord } from "../shared/types.js";
|
|
34
|
+
/** Filename under `<generated>/<harnessSessionId>/`. */
|
|
35
|
+
export declare const ROLLING_SUMMARY_FILE = "summary.md";
|
|
36
|
+
/** Completed turns that must accumulate before a re-fold is worth its cost. */
|
|
37
|
+
export declare const ROLLING_SUMMARY_TURN_INTERVAL = 10;
|
|
38
|
+
/** The contract stated in the prompt, and the clamp applied to what comes back. */
|
|
39
|
+
export declare const ROLLING_SUMMARY_MAX_WORDS = 500;
|
|
40
|
+
/**
|
|
41
|
+
* Model alias for the fold. A summary of text we already hold is the cheapest
|
|
42
|
+
* possible LLM job — pinning a small model here keeps an opt-in background
|
|
43
|
+
* feature from quietly costing what the user's own session does.
|
|
44
|
+
*/
|
|
45
|
+
export declare const ROLLING_SUMMARY_MODEL = "haiku";
|
|
46
|
+
/** Hard turn cap: one turn in, one summary out. */
|
|
47
|
+
export declare const ROLLING_SUMMARY_MAX_TURNS = 1;
|
|
48
|
+
/** Macro id the task is registered under — also TaskManager's dedupe key, so
|
|
49
|
+
* a session can never have two folds in flight at once. */
|
|
50
|
+
export declare const ROLLING_SUMMARY_MACRO_ID = "rolling-summary";
|
|
51
|
+
/**
|
|
52
|
+
* Token budget for the material handed to the fold. Larger than a brief's
|
|
53
|
+
* (this is throwaway input to a cheap model, not a system prompt competing
|
|
54
|
+
* with the user's own context) but still bounded, so a marathon session can't
|
|
55
|
+
* turn one fold into a huge request.
|
|
56
|
+
*/
|
|
57
|
+
export declare const ROLLING_SUMMARY_INPUT_MAX_TOKENS = 20000;
|
|
58
|
+
/** Absolute path of a session's cached summary. */
|
|
59
|
+
export declare function rollingSummaryPath(generatedRoot: string, harnessSessionId: string): string;
|
|
60
|
+
/**
|
|
61
|
+
* The cached summary for a session, or null when there is none — never throws.
|
|
62
|
+
* A brief that fails to find a summary is a brief without one, not an error.
|
|
63
|
+
*/
|
|
64
|
+
export declare function readRollingSummary(generatedRoot: string, harnessSessionId: string): Promise<string | null>;
|
|
65
|
+
/** Trim `text` to at most `maxWords` words, marking the cut. */
|
|
66
|
+
export declare function clampToWords(text: string, maxWords?: number): string;
|
|
67
|
+
/**
|
|
68
|
+
* The one-shot prompt handed to `launchTask`. The material is the record
|
|
69
|
+
* rendered by {@link buildResumeBrief} at a larger budget — deliberately the
|
|
70
|
+
* same renderer the brief uses, so the fold reads exactly the shape a future
|
|
71
|
+
* brief will present, and there is one place where "what a record looks like
|
|
72
|
+
* as text" is defined.
|
|
73
|
+
*
|
|
74
|
+
* `previous` is folded back in rather than discarded: the record itself is
|
|
75
|
+
* capped at the input budget, so on a long session the earliest turns are
|
|
76
|
+
* already gone from the material, and the last summary is the only surviving
|
|
77
|
+
* account of them.
|
|
78
|
+
*/
|
|
79
|
+
export declare function buildRollingSummaryPrompt(record: SessionRecord, previous: string | null): string;
|
|
80
|
+
/** What the summarizer needs from the outside world. All injectable, so the
|
|
81
|
+
* unit tests drive it without a filesystem or a spawned process. */
|
|
82
|
+
export interface RollingSummarizerDeps {
|
|
83
|
+
/** Root the per-session generated dirs live under. */
|
|
84
|
+
generatedRoot: string;
|
|
85
|
+
/** The `HarnessSettings.rollingSummary` gate, re-read per fold so toggling
|
|
86
|
+
* the setting takes effect without a restart. Never throws. */
|
|
87
|
+
enabled: () => Promise<boolean>;
|
|
88
|
+
/** Reads the reconstructed record for a session (SessionRecordReader.read). */
|
|
89
|
+
readRecord: (harnessSessionId: string) => Promise<SessionRecord | null>;
|
|
90
|
+
/** The live session, for the harness kind and cwd the task runs as. Returns
|
|
91
|
+
* undefined once the session is gone from the registry. */
|
|
92
|
+
getSession: (harnessSessionId: string) => {
|
|
93
|
+
harness: HarnessKind;
|
|
94
|
+
cwd: string;
|
|
95
|
+
} | undefined;
|
|
96
|
+
/** TaskManager.run, narrowed to what this needs. */
|
|
97
|
+
runTask: (req: {
|
|
98
|
+
macroId: string;
|
|
99
|
+
label: string;
|
|
100
|
+
harnessSessionId: string;
|
|
101
|
+
harness: HarnessKind;
|
|
102
|
+
cwd: string;
|
|
103
|
+
prompt: string;
|
|
104
|
+
model: string;
|
|
105
|
+
maxTurns: number;
|
|
106
|
+
}) => Promise<BackgroundTask>;
|
|
107
|
+
/** Completed turns between folds. Default {@link ROLLING_SUMMARY_TURN_INTERVAL}. */
|
|
108
|
+
turnInterval?: number;
|
|
109
|
+
/** Diagnostics sink. Defaults to console.error — a fold failing is worth a
|
|
110
|
+
* log line and nothing more. */
|
|
111
|
+
onError?: (err: unknown) => void;
|
|
112
|
+
}
|
|
113
|
+
export interface RollingSummarizer {
|
|
114
|
+
/**
|
|
115
|
+
* Feed one normalized analytics event. Synchronous and total: it counts
|
|
116
|
+
* `turn.completed`, triggers a fold on the interval and on `session.end`,
|
|
117
|
+
* and ignores everything else. Any work it starts is detached.
|
|
118
|
+
*/
|
|
119
|
+
noteEvent(event: AnalyticsEvent): void;
|
|
120
|
+
/**
|
|
121
|
+
* Feed a task status change (TaskManager.onStatusChange). A completed fold
|
|
122
|
+
* task's `resultText` is what becomes `summary.md`.
|
|
123
|
+
*/
|
|
124
|
+
noteTaskStatus(task: BackgroundTask): void;
|
|
125
|
+
/** Resolves when no fold this summarizer started is still in flight. For
|
|
126
|
+
* tests and for shutdown; production callers never need to await it. */
|
|
127
|
+
idle(): Promise<void>;
|
|
128
|
+
}
|
|
129
|
+
export declare function createRollingSummarizer(deps: RollingSummarizerDeps): RollingSummarizer;
|
|
130
|
+
//# sourceMappingURL=rolling-summary.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"rolling-summary.d.ts","sourceRoot":"","sources":["../../src/core/rolling-summary.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AAKH,OAAO,KAAK,EAAE,cAAc,EAAE,cAAc,EAAE,WAAW,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AAGrG,wDAAwD;AACxD,eAAO,MAAM,oBAAoB,eAAe,CAAC;AACjD,+EAA+E;AAC/E,eAAO,MAAM,6BAA6B,KAAK,CAAC;AAChD,mFAAmF;AACnF,eAAO,MAAM,yBAAyB,MAAM,CAAC;AAC7C;;;;GAIG;AACH,eAAO,MAAM,qBAAqB,UAAU,CAAC;AAC7C,mDAAmD;AACnD,eAAO,MAAM,yBAAyB,IAAI,CAAC;AAC3C;4DAC4D;AAC5D,eAAO,MAAM,wBAAwB,oBAAoB,CAAC;AAC1D;;;;;GAKG;AACH,eAAO,MAAM,gCAAgC,QAAS,CAAC;AAEvD,mDAAmD;AACnD,wBAAgB,kBAAkB,CAAC,aAAa,EAAE,MAAM,EAAE,gBAAgB,EAAE,MAAM,GAAG,MAAM,CAE1F;AAED;;;GAGG;AACH,wBAAsB,kBAAkB,CACtC,aAAa,EAAE,MAAM,EACrB,gBAAgB,EAAE,MAAM,GACvB,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAQxB;AAED,gEAAgE;AAChE,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,GAAE,MAAkC,GAAG,MAAM,CAI/F;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,yBAAyB,CAAC,MAAM,EAAE,aAAa,EAAE,QAAQ,EAAE,MAAM,GAAG,IAAI,GAAG,MAAM,CA+BhG;AAED;qEACqE;AACrE,MAAM,WAAW,qBAAqB;IACpC,sDAAsD;IACtD,aAAa,EAAE,MAAM,CAAC;IACtB;oEACgE;IAChE,OAAO,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,CAAC;IAChC,+EAA+E;IAC/E,UAAU,EAAE,CAAC,gBAAgB,EAAE,MAAM,KAAK,OAAO,CAAC,aAAa,GAAG,IAAI,CAAC,CAAC;IACxE;gEAC4D;IAC5D,UAAU,EAAE,CAAC,gBAAgB,EAAE,MAAM,KAAK;QAAE,OAAO,EAAE,WAAW,CAAC;QAAC,GAAG,EAAE,MAAM,CAAA;KAAE,GAAG,SAAS,CAAC;IAC5F,oDAAoD;IACpD,OAAO,EAAE,CAAC,GAAG,EAAE;QACb,OAAO,EAAE,MAAM,CAAC;QAChB,KAAK,EAAE,MAAM,CAAC;QACd,gBAAgB,EAAE,MAAM,CAAC;QACzB,OAAO,EAAE,WAAW,CAAC;QACrB,GAAG,EAAE,MAAM,CAAC;QACZ,MAAM,EAAE,MAAM,CAAC;QACf,KAAK,EAAE,MAAM,CAAC;QACd,QAAQ,EAAE,MAAM,CAAC;KAClB,KAAK,OAAO,CAAC,cAAc,CAAC,CAAC;IAC9B,oFAAoF;IACpF,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB;qCACiC;IACjC,OAAO,CAAC,EAAE,CAAC,GAAG,EAAE,OAAO,KAAK,IAAI,CAAC;CAClC;AAED,MAAM,WAAW,iBAAiB;IAChC;;;;OAIG;IACH,SAAS,CAAC,KAAK,EAAE,cAAc,GAAG,IAAI,CAAC;IACvC;;;OAGG;IACH,cAAc,CAAC,IAAI,EAAE,cAAc,GAAG,IAAI,CAAC;IAC3C;6EACyE;IACzE,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACvB;AAED,wBAAgB,uBAAuB,CAAC,IAAI,EAAE,qBAAqB,GAAG,iBAAiB,CAgGtF"}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rolling session summary — the thing that makes rehydration good rather than
|
|
3
|
+
* merely possible.
|
|
4
|
+
*
|
|
5
|
+
* A resume brief built from the last N turns alone tells a fresh session what
|
|
6
|
+
* just happened; it cannot tell it what the session was *for*. So, opt-in, we
|
|
7
|
+
* periodically fold the whole record down to a ≤500-word summary and cache it
|
|
8
|
+
* at `<generated>/<harnessSessionId>/summary.md`. The brief
|
|
9
|
+
* (core/resume-brief.ts) picks it up when it's there and degrades cleanly to
|
|
10
|
+
* last-N-turns when it isn't — which is the default, since this is
|
|
11
|
+
* setting-gated (`HarnessSettings.rollingSummary`).
|
|
12
|
+
*
|
|
13
|
+
* NEVER BLOCKS A TURN. Everything here is fire-and-forget off the ingest path:
|
|
14
|
+
* `noteEvent` returns synchronously, the fold runs as a headless one-shot
|
|
15
|
+
* `TaskManager` job (a separate process, a cheap model, `--max-turns 1` — the
|
|
16
|
+
* bounded shape `HarnessAdapter.launchTask` exists for), and every failure
|
|
17
|
+
* mode short-circuits to "no summary". A user's session must never be slower,
|
|
18
|
+
* or fail, because a summary didn't happen.
|
|
19
|
+
*
|
|
20
|
+
* The task does not write the file itself. It is asked for the summary text
|
|
21
|
+
* and nothing else; we write `summary.md` from its `resultText` when it
|
|
22
|
+
* completes. Two reasons: the target lives under `<generated>/`, outside the
|
|
23
|
+
* session's cwd — which codex's `workspace-write` sandbox forbids and
|
|
24
|
+
* claude-code's `acceptEdits` shouldn't be asked to reach — and a task that
|
|
25
|
+
* writes its own output can half-write it, where a task that returns text
|
|
26
|
+
* either produces a whole summary or none.
|
|
27
|
+
*
|
|
28
|
+
* WHERE IT DOESN'T WORK: `launchTask` is optional on the adapter contract and
|
|
29
|
+
* codex has no non-interactive mode wired for it, so a codex session simply
|
|
30
|
+
* never produces a summary and its briefs are last-N-turns. That is a real
|
|
31
|
+
* gap, stated rather than papered over; the brief is honest either way.
|
|
32
|
+
*/
|
|
33
|
+
import * as fs from "node:fs/promises";
|
|
34
|
+
import * as path from "node:path";
|
|
35
|
+
import { buildResumeBrief } from "./resume-brief.js";
|
|
36
|
+
/** Filename under `<generated>/<harnessSessionId>/`. */
|
|
37
|
+
export const ROLLING_SUMMARY_FILE = "summary.md";
|
|
38
|
+
/** Completed turns that must accumulate before a re-fold is worth its cost. */
|
|
39
|
+
export const ROLLING_SUMMARY_TURN_INTERVAL = 10;
|
|
40
|
+
/** The contract stated in the prompt, and the clamp applied to what comes back. */
|
|
41
|
+
export const ROLLING_SUMMARY_MAX_WORDS = 500;
|
|
42
|
+
/**
|
|
43
|
+
* Model alias for the fold. A summary of text we already hold is the cheapest
|
|
44
|
+
* possible LLM job — pinning a small model here keeps an opt-in background
|
|
45
|
+
* feature from quietly costing what the user's own session does.
|
|
46
|
+
*/
|
|
47
|
+
export const ROLLING_SUMMARY_MODEL = "haiku";
|
|
48
|
+
/** Hard turn cap: one turn in, one summary out. */
|
|
49
|
+
export const ROLLING_SUMMARY_MAX_TURNS = 1;
|
|
50
|
+
/** Macro id the task is registered under — also TaskManager's dedupe key, so
|
|
51
|
+
* a session can never have two folds in flight at once. */
|
|
52
|
+
export const ROLLING_SUMMARY_MACRO_ID = "rolling-summary";
|
|
53
|
+
/**
|
|
54
|
+
* Token budget for the material handed to the fold. Larger than a brief's
|
|
55
|
+
* (this is throwaway input to a cheap model, not a system prompt competing
|
|
56
|
+
* with the user's own context) but still bounded, so a marathon session can't
|
|
57
|
+
* turn one fold into a huge request.
|
|
58
|
+
*/
|
|
59
|
+
export const ROLLING_SUMMARY_INPUT_MAX_TOKENS = 20000;
|
|
60
|
+
/** Absolute path of a session's cached summary. */
|
|
61
|
+
export function rollingSummaryPath(generatedRoot, harnessSessionId) {
|
|
62
|
+
return path.join(generatedRoot, harnessSessionId, ROLLING_SUMMARY_FILE);
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* The cached summary for a session, or null when there is none — never throws.
|
|
66
|
+
* A brief that fails to find a summary is a brief without one, not an error.
|
|
67
|
+
*/
|
|
68
|
+
export async function readRollingSummary(generatedRoot, harnessSessionId) {
|
|
69
|
+
try {
|
|
70
|
+
const text = await fs.readFile(rollingSummaryPath(generatedRoot, harnessSessionId), "utf8");
|
|
71
|
+
const trimmed = text.trim();
|
|
72
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/** Trim `text` to at most `maxWords` words, marking the cut. */
|
|
79
|
+
export function clampToWords(text, maxWords = ROLLING_SUMMARY_MAX_WORDS) {
|
|
80
|
+
const words = text.trim().split(/\s+/);
|
|
81
|
+
if (words.length <= maxWords)
|
|
82
|
+
return text.trim();
|
|
83
|
+
return `${words.slice(0, maxWords).join(" ")} …[summary truncated]`;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* The one-shot prompt handed to `launchTask`. The material is the record
|
|
87
|
+
* rendered by {@link buildResumeBrief} at a larger budget — deliberately the
|
|
88
|
+
* same renderer the brief uses, so the fold reads exactly the shape a future
|
|
89
|
+
* brief will present, and there is one place where "what a record looks like
|
|
90
|
+
* as text" is defined.
|
|
91
|
+
*
|
|
92
|
+
* `previous` is folded back in rather than discarded: the record itself is
|
|
93
|
+
* capped at the input budget, so on a long session the earliest turns are
|
|
94
|
+
* already gone from the material, and the last summary is the only surviving
|
|
95
|
+
* account of them.
|
|
96
|
+
*/
|
|
97
|
+
export function buildRollingSummaryPrompt(record, previous) {
|
|
98
|
+
const material = buildResumeBrief(record, {
|
|
99
|
+
summary: previous,
|
|
100
|
+
maxTokens: ROLLING_SUMMARY_INPUT_MAX_TOKENS,
|
|
101
|
+
maxTurns: Number.MAX_SAFE_INTEGER,
|
|
102
|
+
});
|
|
103
|
+
return [
|
|
104
|
+
`Summarize the coding session described below in at most ${ROLLING_SUMMARY_MAX_WORDS} words.`,
|
|
105
|
+
"",
|
|
106
|
+
"The summary is read by a FRESH agent session that has none of this context,",
|
|
107
|
+
"so write for someone picking the work up cold. Cover, in this order and only",
|
|
108
|
+
"where the material actually supports it:",
|
|
109
|
+
"",
|
|
110
|
+
"1. What the session was trying to accomplish.",
|
|
111
|
+
"2. What was decided, and why — decisions are the part a transcript replay",
|
|
112
|
+
" cannot reconstruct.",
|
|
113
|
+
"3. What was actually changed (files, behaviour).",
|
|
114
|
+
"4. What is unfinished, blocked, or known-broken.",
|
|
115
|
+
"",
|
|
116
|
+
"Rules:",
|
|
117
|
+
"- Output ONLY the summary as markdown prose. No preamble, no sign-off, no",
|
|
118
|
+
" code fences around the whole thing.",
|
|
119
|
+
"- Do not invent anything the material does not state. If something is",
|
|
120
|
+
" unclear or was never recorded, say so or leave it out — a confident",
|
|
121
|
+
" summary of a gap is the single worst outcome here.",
|
|
122
|
+
"- Do not use any tools. Everything you need is in this prompt.",
|
|
123
|
+
"",
|
|
124
|
+
"--- SESSION MATERIAL ---",
|
|
125
|
+
"",
|
|
126
|
+
material,
|
|
127
|
+
].join("\n");
|
|
128
|
+
}
|
|
129
|
+
export function createRollingSummarizer(deps) {
|
|
130
|
+
const turnInterval = deps.turnInterval ?? ROLLING_SUMMARY_TURN_INTERVAL;
|
|
131
|
+
const onError = deps.onError ?? ((err) => console.error("[harness] rolling summary failed:", err));
|
|
132
|
+
/** Completed turns seen for a session since its last fold was *started*. */
|
|
133
|
+
const turnsSinceFold = new Map();
|
|
134
|
+
/** Fold task id → the session it summarizes, so a completion can be routed
|
|
135
|
+
* back. Only ids this summarizer started are ever in here, which is what
|
|
136
|
+
* keeps it from writing summary.md for some other background task. */
|
|
137
|
+
const foldTargets = new Map();
|
|
138
|
+
/** In-flight promises, awaited by `idle()`. */
|
|
139
|
+
const pending = new Set();
|
|
140
|
+
const track = (work) => {
|
|
141
|
+
const tracked = work.catch(onError).finally(() => pending.delete(tracked));
|
|
142
|
+
pending.add(tracked);
|
|
143
|
+
};
|
|
144
|
+
async function fold(harnessSessionId) {
|
|
145
|
+
if (!(await deps.enabled()))
|
|
146
|
+
return;
|
|
147
|
+
const session = deps.getSession(harnessSessionId);
|
|
148
|
+
// Gone from the registry (swept, or a task's own id leaking through the
|
|
149
|
+
// ingest path) — there is nothing to run the fold as.
|
|
150
|
+
if (!session)
|
|
151
|
+
return;
|
|
152
|
+
const record = await deps.readRecord(harnessSessionId);
|
|
153
|
+
if (!record || record.turns.length === 0)
|
|
154
|
+
return;
|
|
155
|
+
const previous = await readRollingSummary(deps.generatedRoot, harnessSessionId);
|
|
156
|
+
const task = await deps.runTask({
|
|
157
|
+
macroId: ROLLING_SUMMARY_MACRO_ID,
|
|
158
|
+
label: "Session summary",
|
|
159
|
+
harnessSessionId,
|
|
160
|
+
harness: session.harness,
|
|
161
|
+
cwd: session.cwd,
|
|
162
|
+
prompt: buildRollingSummaryPrompt(record, previous),
|
|
163
|
+
model: ROLLING_SUMMARY_MODEL,
|
|
164
|
+
maxTurns: ROLLING_SUMMARY_MAX_TURNS,
|
|
165
|
+
});
|
|
166
|
+
foldTargets.set(task.id, harnessSessionId);
|
|
167
|
+
}
|
|
168
|
+
async function writeSummary(harnessSessionId, text) {
|
|
169
|
+
const clamped = clampToWords(text.trim());
|
|
170
|
+
if (clamped.length === 0)
|
|
171
|
+
return;
|
|
172
|
+
const filePath = rollingSummaryPath(deps.generatedRoot, harnessSessionId);
|
|
173
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
174
|
+
await fs.writeFile(filePath, `${clamped}\n`, "utf8");
|
|
175
|
+
}
|
|
176
|
+
return {
|
|
177
|
+
noteEvent(event) {
|
|
178
|
+
const sessionId = event.harnessSessionId;
|
|
179
|
+
if (event.type === "turn.completed") {
|
|
180
|
+
const count = (turnsSinceFold.get(sessionId) ?? 0) + 1;
|
|
181
|
+
if (count < turnInterval) {
|
|
182
|
+
turnsSinceFold.set(sessionId, count);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
// Reset BEFORE the fold rather than after it completes: the counter
|
|
186
|
+
// paces how often folds are *started*, and resetting on completion
|
|
187
|
+
// would let a slow fold queue up a second one the moment it lands.
|
|
188
|
+
turnsSinceFold.set(sessionId, 0);
|
|
189
|
+
track(fold(sessionId));
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
if (event.type === "session.end") {
|
|
193
|
+
// Always fold at session end when anything at all happened since the
|
|
194
|
+
// last one — this is the summary a future rehydration will actually
|
|
195
|
+
// read, so it must not be `turnInterval - 1` turns stale.
|
|
196
|
+
const pendingTurns = turnsSinceFold.get(sessionId) ?? 0;
|
|
197
|
+
turnsSinceFold.delete(sessionId);
|
|
198
|
+
if (pendingTurns > 0)
|
|
199
|
+
track(fold(sessionId));
|
|
200
|
+
}
|
|
201
|
+
},
|
|
202
|
+
noteTaskStatus(task) {
|
|
203
|
+
const harnessSessionId = foldTargets.get(task.id);
|
|
204
|
+
if (harnessSessionId === undefined)
|
|
205
|
+
return;
|
|
206
|
+
if (task.status === "running")
|
|
207
|
+
return;
|
|
208
|
+
foldTargets.delete(task.id);
|
|
209
|
+
if (task.status === "failed" || !task.resultText) {
|
|
210
|
+
// A failed fold leaves the previous summary in place — a stale summary
|
|
211
|
+
// is strictly better than none, and the brief labels its own age via
|
|
212
|
+
// the record's own timestamps either way.
|
|
213
|
+
if (task.status === "failed")
|
|
214
|
+
onError(new Error(task.errorTail ?? "rolling summary task failed"));
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
track(writeSummary(harnessSessionId, task.resultText));
|
|
218
|
+
},
|
|
219
|
+
async idle() {
|
|
220
|
+
// Re-read the set each pass: writing a summary is itself tracked work
|
|
221
|
+
// started by an earlier tracked promise.
|
|
222
|
+
while (pending.size > 0)
|
|
223
|
+
await Promise.all([...pending]);
|
|
224
|
+
},
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
//# sourceMappingURL=rolling-summary.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"rolling-summary.js","sourceRoot":"","sources":["../../src/core/rolling-summary.ts"],"names":[],"mappings":"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"}
|
|
@@ -35,7 +35,7 @@ export type SessionActivityListener = (harnessSessionId: string) => void;
|
|
|
35
35
|
* whichever shape is handed in; existing sync test doubles keep working
|
|
36
36
|
* unchanged (`await` on a plain value just resolves immediately).
|
|
37
37
|
*/
|
|
38
|
-
export type LaunchOptsBuilder = (harnessSessionId: string, req: Pick<CreateSessionRequest, "cwd" | "harness" | "profile">) => Omit<LaunchOpts, "harnessSessionId" | "cwd"> | Promise<Omit<LaunchOpts, "harnessSessionId" | "cwd">>;
|
|
38
|
+
export type LaunchOptsBuilder = (harnessSessionId: string, req: Pick<CreateSessionRequest, "cwd" | "harness" | "profile" | "rehydrateFrom">) => Omit<LaunchOpts, "harnessSessionId" | "cwd"> | Promise<Omit<LaunchOpts, "harnessSessionId" | "cwd">>;
|
|
39
39
|
export interface SessionManagerOptions {
|
|
40
40
|
adapters: Partial<Record<HarnessKind, HarnessAdapter>>;
|
|
41
41
|
/** Base URL the harness server is reachable at, e.g. http://127.0.0.1:4100. */
|
|
@@ -125,8 +125,16 @@ export declare class SessionManager {
|
|
|
125
125
|
create(req: CreateSessionRequest): Promise<HarnessSession>;
|
|
126
126
|
/**
|
|
127
127
|
* Registers a purely historical (never-launched-by-this-harness) session so
|
|
128
|
-
* it can subsequently be resumed via `resume()`.
|
|
129
|
-
* when a user
|
|
128
|
+
* it can subsequently be resumed via `resume()`. Called by
|
|
129
|
+
* `POST /api/sessions/adopt` when a user opens a transcript-only history row
|
|
130
|
+
* whose transcript the agent genuinely still holds — that row then resumes
|
|
131
|
+
* for real instead of silently starting a fresh session in the directory,
|
|
132
|
+
* which is what happened for as long as nothing called this.
|
|
133
|
+
*
|
|
134
|
+
* The record is born `exited` with `createdAt === lastActiveAt` (nothing has
|
|
135
|
+
* run under our management yet). `resume()` is what gives it a pty, and the
|
|
136
|
+
* adopt route pre-verifies resumability before registering, so this never
|
|
137
|
+
* mints a record that can't be resumed.
|
|
130
138
|
*/
|
|
131
139
|
registerHistorical(input: {
|
|
132
140
|
agentSessionId: string;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"session-manager.d.ts","sourceRoot":"","sources":["../../src/core/session-manager.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAQH,OAAO,EAGL,KAAK,oBAAoB,EACzB,KAAK,cAAc,EACnB,KAAK,WAAW,EAChB,KAAK,cAAc,EACnB,KAAK,UAAU,EAEhB,MAAM,oBAAoB,CAAC;
|
|
1
|
+
{"version":3,"file":"session-manager.d.ts","sourceRoot":"","sources":["../../src/core/session-manager.ts"],"names":[],"mappings":"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"}
|
|
@@ -11,6 +11,7 @@ import { basename, dirname, join, resolve, sep } from "node:path";
|
|
|
11
11
|
import { createRequire } from "node:module";
|
|
12
12
|
import { ENV, HARNESS_PATHS, } from "../shared/types.js";
|
|
13
13
|
import { expandHome } from "./paths.js";
|
|
14
|
+
import { resolveSpawnTarget } from "./spawn-target.js";
|
|
14
15
|
import { AdapterNotFoundError, ExternalHarnessError, SessionAlreadyLiveError, SessionNotReadyError, SessionNotResumeableError, UnknownSessionError, } from "./errors.js";
|
|
15
16
|
import { listHarnessAdapters } from "./adapters/registry.js";
|
|
16
17
|
export { AdapterNotFoundError, ExternalHarnessError, SessionAlreadyLiveError, SessionNotReadyError, SessionNotResumeableError, UnknownSessionError, } from "./errors.js";
|
|
@@ -245,6 +246,11 @@ export class SessionManager {
|
|
|
245
246
|
lastActiveAt: this.now(),
|
|
246
247
|
exitCode: null,
|
|
247
248
|
boundWorkflowPath: null,
|
|
249
|
+
// What the builder actually managed to assemble, not what the caller
|
|
250
|
+
// asked for: `req.rehydrateFrom` naming a session our event log holds
|
|
251
|
+
// nothing for yields no brief, and this stays null so nothing downstream
|
|
252
|
+
// presents an empty-handed fresh session as a continuation.
|
|
253
|
+
rehydratedFrom: opts.rehydratedFrom ?? null,
|
|
248
254
|
ready: false,
|
|
249
255
|
};
|
|
250
256
|
this.sessions.set(id, session);
|
|
@@ -271,8 +277,16 @@ export class SessionManager {
|
|
|
271
277
|
}
|
|
272
278
|
/**
|
|
273
279
|
* Registers a purely historical (never-launched-by-this-harness) session so
|
|
274
|
-
* it can subsequently be resumed via `resume()`.
|
|
275
|
-
* when a user
|
|
280
|
+
* it can subsequently be resumed via `resume()`. Called by
|
|
281
|
+
* `POST /api/sessions/adopt` when a user opens a transcript-only history row
|
|
282
|
+
* whose transcript the agent genuinely still holds — that row then resumes
|
|
283
|
+
* for real instead of silently starting a fresh session in the directory,
|
|
284
|
+
* which is what happened for as long as nothing called this.
|
|
285
|
+
*
|
|
286
|
+
* The record is born `exited` with `createdAt === lastActiveAt` (nothing has
|
|
287
|
+
* run under our management yet). `resume()` is what gives it a pty, and the
|
|
288
|
+
* adopt route pre-verifies resumability before registering, so this never
|
|
289
|
+
* mints a record that can't be resumed.
|
|
276
290
|
*/
|
|
277
291
|
registerHistorical(input) {
|
|
278
292
|
const id = this.generateId();
|
|
@@ -304,12 +318,32 @@ export class SessionManager {
|
|
|
304
318
|
throw new SessionAlreadyLiveError(id);
|
|
305
319
|
}
|
|
306
320
|
const adapter = this.getAdapter(session.harness);
|
|
321
|
+
// Pre-flight against the agent's OWN store before touching the record.
|
|
322
|
+
// Holding an agentSessionId only means our SessionStart hook fired once;
|
|
323
|
+
// an agent that never received a prompt writes no transcript, so
|
|
324
|
+
// `--resume` would exit 1 with "No conversation found with session ID"
|
|
325
|
+
// and leave the user on a dead pane offering Resume all over again.
|
|
326
|
+
// Failing here instead keeps the record exactly as it was — unspawned,
|
|
327
|
+
// and (see below) with its real lastActiveAt intact.
|
|
328
|
+
if (!(await adapter.canResume(session.agentSessionId, session.cwd))) {
|
|
329
|
+
const label = listHarnessAdapters().find((a) => a.id === session.harness)?.label ?? session.harness;
|
|
330
|
+
throw new SessionNotResumeableError(id, `${label} no longer has the conversation for this session (${session.agentSessionId}) in ${session.cwd}. ` +
|
|
331
|
+
`Sessions that ended before their first prompt are never written to the agent's history, so there is nothing to resume — start a new session in this directory instead.`);
|
|
332
|
+
}
|
|
307
333
|
const opts = {
|
|
308
334
|
harnessSessionId: id,
|
|
309
335
|
cwd: session.cwd,
|
|
310
336
|
...(await this.buildLaunchOpts(id, session)),
|
|
311
337
|
};
|
|
312
338
|
const spec = adapter.resume(session.agentSessionId, opts);
|
|
339
|
+
// Kept so the failure path below can put it back: `lastActiveAt` is
|
|
340
|
+
// stamped here only to keep sweepDeadSessions() from reaping this record
|
|
341
|
+
// during the pre-pty window (it reaps non-exited records with no pty once
|
|
342
|
+
// they're older than the grace period). If the resume never produces a
|
|
343
|
+
// pty, that stamp is not activity and must not survive — otherwise a
|
|
344
|
+
// session idle since last night reports "Ran for 6h 25m" purely because
|
|
345
|
+
// someone clicked Resume.
|
|
346
|
+
const lastActiveBeforeResume = session.lastActiveAt;
|
|
313
347
|
session.status = "starting";
|
|
314
348
|
session.exitCode = null;
|
|
315
349
|
session.lastActiveAt = this.now();
|
|
@@ -332,8 +366,12 @@ export class SessionManager {
|
|
|
332
366
|
catch (err) {
|
|
333
367
|
// Same reconciliation as create(): the record just went back to
|
|
334
368
|
// "starting" and was persisted — a failure before the new pty is live
|
|
335
|
-
// must not leave it stranded there with nothing behind it.
|
|
336
|
-
|
|
369
|
+
// must not leave it stranded there with nothing behind it. Roll the
|
|
370
|
+
// pre-pty `lastActiveAt` stamp back at the same time: no pty ever ran,
|
|
371
|
+
// so the session's last real activity is still where it was, and the
|
|
372
|
+
// dead pane's "Ran for" stays truthful.
|
|
373
|
+
session.lastActiveAt = lastActiveBeforeResume;
|
|
374
|
+
await this.transitionExited(session, null, { stampLastActive: false });
|
|
337
375
|
throw err;
|
|
338
376
|
}
|
|
339
377
|
return session;
|
|
@@ -701,11 +739,18 @@ export class SessionManager {
|
|
|
701
739
|
env[ENV.sessionId] = session.id;
|
|
702
740
|
if (this.collectorUrl)
|
|
703
741
|
env[ENV.collectorUrl] = this.collectorUrl;
|
|
742
|
+
// On Windows a bare command name (or a .cmd shim, which is what npm installs
|
|
743
|
+
// for `claude`) cannot be spawned: node-pty uses CreateProcess, which does no
|
|
744
|
+
// PATHEXT resolution and can't execute a .cmd. resolveSpawnTarget RESOLVES the
|
|
745
|
+
// shim to its real target — deliberately NOT via cmd.exe, which would expose
|
|
746
|
+
// these arguments to shell parsing (command injection; see that module).
|
|
747
|
+
// No-op on POSIX.
|
|
748
|
+
const target = resolveSpawnTarget(spec.command, spec.args);
|
|
704
749
|
// A throw here — spawnFn itself, or loadDefaultSpawn() above (a broken
|
|
705
750
|
// node-pty prebuild surfaces there, not at import time) — propagates to
|
|
706
751
|
// create()/resume(), which own reconciling the session record to
|
|
707
752
|
// "exited" for every pre-pty failure, not just this one.
|
|
708
|
-
const pty = spawnFn(
|
|
753
|
+
const pty = spawnFn(target.command, target.args, {
|
|
709
754
|
name: "xterm-256color",
|
|
710
755
|
cols: DEFAULT_COLS,
|
|
711
756
|
rows: DEFAULT_ROWS,
|
|
@@ -765,10 +810,14 @@ export class SessionManager {
|
|
|
765
810
|
* the registry durably updated before rethrowing (create/resume) can
|
|
766
811
|
* await it; event-driven callers fire-and-forget it like any other write.
|
|
767
812
|
*/
|
|
768
|
-
transitionExited(session, exitCode) {
|
|
813
|
+
transitionExited(session, exitCode, { stampLastActive = true } = {}) {
|
|
769
814
|
session.status = "exited";
|
|
770
815
|
session.exitCode = exitCode;
|
|
771
|
-
|
|
816
|
+
// `stampLastActive: false` is for reconciling a resume that never got a
|
|
817
|
+
// pty: "we noticed it's dead" is not activity, and stamping it there is
|
|
818
|
+
// what made an untouched session's duration grow on every failed Resume.
|
|
819
|
+
if (stampLastActive)
|
|
820
|
+
session.lastActiveAt = this.now();
|
|
772
821
|
const persisted = this.persist();
|
|
773
822
|
this.emitStatus(session);
|
|
774
823
|
return persisted;
|