@itookit/dsht 0.3.7 → 0.5.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.
Files changed (132) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +31 -11
  3. package/README.zh.md +31 -11
  4. package/dist/cli/dsht.js +207 -19
  5. package/dist/cli/startup.d.ts +40 -0
  6. package/dist/cli/startup.js +295 -0
  7. package/dist/cli/trace-summary.d.ts +78 -0
  8. package/dist/cli/trace-summary.js +241 -0
  9. package/dist/cli/verifier.d.ts +60 -0
  10. package/dist/cli/verifier.js +242 -0
  11. package/dist/contracts.d.ts +344 -0
  12. package/dist/contracts.js +1 -0
  13. package/dist/controller/commands.d.ts +47 -0
  14. package/dist/controller/commands.js +322 -0
  15. package/dist/controller/connection.d.ts +11 -29
  16. package/dist/controller/connection.js +26 -60
  17. package/dist/controller/controller.d.ts +619 -164
  18. package/dist/controller/controller.js +1420 -141
  19. package/dist/controller/index.d.ts +8 -1
  20. package/dist/controller/index.js +5 -0
  21. package/dist/controller/loop-contract.d.ts +136 -0
  22. package/dist/controller/loop-contract.js +308 -0
  23. package/dist/controller/loop-prompts-schema.d.ts +56 -0
  24. package/dist/controller/loop-prompts-schema.js +144 -0
  25. package/dist/controller/loop-prompts.d.ts +55 -0
  26. package/dist/controller/loop-prompts.generated.d.ts +104 -0
  27. package/dist/controller/loop-prompts.generated.js +185 -0
  28. package/dist/controller/loop-prompts.js +104 -0
  29. package/dist/controller/loop-protocols.d.ts +39 -0
  30. package/dist/controller/loop-protocols.js +115 -0
  31. package/dist/controller/loop.d.ts +275 -0
  32. package/dist/controller/loop.js +378 -0
  33. package/dist/controller/prompts.d.ts +54 -0
  34. package/dist/controller/prompts.js +162 -0
  35. package/dist/controller/trace-log.d.ts +45 -0
  36. package/dist/controller/trace-log.js +144 -0
  37. package/dist/controller/verifier.d.ts +126 -0
  38. package/dist/controller/verifier.js +75 -0
  39. package/dist/cost/index.d.ts +1 -1
  40. package/dist/cost/index.js +1 -1
  41. package/dist/cost/ledger.d.ts +0 -1
  42. package/dist/cost/ledger.js +0 -1
  43. package/dist/json.d.ts +18 -0
  44. package/dist/json.js +19 -0
  45. package/dist/references.d.ts +25 -0
  46. package/dist/references.js +26 -0
  47. package/dist/session/connection-view.d.ts +2 -11
  48. package/dist/session/controller.d.ts +82 -72
  49. package/dist/session/controller.js +211 -209
  50. package/dist/session/history.d.ts +9 -1
  51. package/dist/session/history.js +1 -9
  52. package/dist/session/index.d.ts +9 -4
  53. package/dist/session/index.js +7 -3
  54. package/dist/session/info.d.ts +25 -52
  55. package/dist/session/info.js +39 -25
  56. package/dist/session/markdown.js +1 -1
  57. package/dist/session/math.js +1 -1
  58. package/dist/session/mutation-gate.d.ts +51 -0
  59. package/dist/session/mutation-gate.js +73 -0
  60. package/dist/session/navigation.d.ts +2 -89
  61. package/dist/session/navigation.js +2 -129
  62. package/dist/session/peek.d.ts +38 -0
  63. package/dist/session/peek.js +103 -0
  64. package/dist/session/references.d.ts +2 -20
  65. package/dist/session/references.js +1 -26
  66. package/dist/session/runtime.d.ts +26 -0
  67. package/dist/session/runtime.js +28 -0
  68. package/dist/session/telemetry.d.ts +12 -13
  69. package/dist/session/telemetry.js +27 -58
  70. package/dist/session/transcript.d.ts +0 -6
  71. package/dist/session/transcript.js +2 -15
  72. package/dist/session/types.d.ts +25 -0
  73. package/dist/session/types.js +0 -1
  74. package/dist/session-title.d.ts +9 -0
  75. package/dist/session-title.js +21 -0
  76. package/dist/shell/controller.d.ts +97 -0
  77. package/dist/shell/controller.js +158 -0
  78. package/dist/shell/index.d.ts +5 -0
  79. package/dist/shell/index.js +3 -0
  80. package/dist/shell/runner.d.ts +38 -0
  81. package/dist/shell/runner.js +147 -0
  82. package/dist/slash/index.d.ts +10 -0
  83. package/dist/slash/index.js +7 -0
  84. package/dist/slash/parse.d.ts +166 -0
  85. package/dist/slash/parse.js +259 -0
  86. package/dist/slash/pipeline.d.ts +140 -0
  87. package/dist/slash/pipeline.js +115 -0
  88. package/dist/slash/registry.d.ts +88 -0
  89. package/dist/slash/registry.js +177 -0
  90. package/dist/state.d.ts +14 -4
  91. package/dist/state.js +3 -2
  92. package/dist/text.d.ts +28 -0
  93. package/dist/text.js +55 -0
  94. package/dist/transport/events.d.ts +104 -0
  95. package/dist/transport/events.js +149 -0
  96. package/dist/transport/wire.d.ts +9 -17
  97. package/dist/transport/wire.js +2 -27
  98. package/dist/ui/app.js +865 -431
  99. package/dist/ui/chat/header.js +1 -1
  100. package/dist/ui/chat/history-view.d.ts +1 -1
  101. package/dist/ui/chat/history-view.js +1 -1
  102. package/dist/ui/chat/loop-status.d.ts +11 -0
  103. package/dist/ui/chat/loop-status.js +28 -0
  104. package/dist/ui/chat/navigation-model.d.ts +86 -0
  105. package/dist/ui/chat/navigation-model.js +107 -0
  106. package/dist/ui/chat/shell-view.d.ts +47 -0
  107. package/dist/ui/chat/shell-view.js +145 -0
  108. package/dist/ui/chat/status.d.ts +47 -3
  109. package/dist/ui/chat/status.js +65 -50
  110. package/dist/ui/chat/viewport.d.ts +1 -1
  111. package/dist/ui/dialogs/cost.d.ts +21 -4
  112. package/dist/ui/dialogs/cost.js +7 -12
  113. package/dist/ui/dialogs/index.d.ts +22 -5
  114. package/dist/ui/dialogs/index.js +19 -3
  115. package/dist/ui/dialogs/loop.d.ts +43 -0
  116. package/dist/ui/dialogs/loop.js +224 -0
  117. package/dist/ui/dialogs/peek.d.ts +25 -0
  118. package/dist/ui/dialogs/peek.js +35 -0
  119. package/dist/ui/dialogs/picker.d.ts +2 -0
  120. package/dist/ui/dialogs/picker.js +4 -2
  121. package/dist/ui/input/mouse.d.ts +12 -2
  122. package/dist/ui/input/mouse.js +20 -7
  123. package/dist/ui/input/references.d.ts +1 -1
  124. package/dist/ui/status/model.d.ts +7 -0
  125. package/dist/ui/status/model.js +5 -0
  126. package/dist/ui/theme/index.d.ts +6 -1
  127. package/dist/ui/theme/index.js +2 -1
  128. package/package.json +6 -4
  129. package/dist/ui/commands/parse.d.ts +0 -99
  130. package/dist/ui/commands/parse.js +0 -126
  131. package/dist/ui/commands/registry.d.ts +0 -33
  132. package/dist/ui/commands/registry.js +0 -73
@@ -1,25 +1,5 @@
1
- /** Shared display names and unambiguous slash-command target resolution. */
2
- import { safeText, string } from "../transport/wire.js";
3
- /** Parse workspace and resume navigation, including their long aliases. */
4
- export function navigationCommand(value) {
5
- const match = /^\/(ws|workspace|workspaces|resume|session|sessions)(?:\s+(.+))?$/.exec(value);
6
- if (!match)
7
- return undefined;
8
- return { kind: match[1] === 'ws' || match[1].startsWith('workspace') ? 'workspace' : 'session', query: match[2] };
9
- }
10
- /** Resolve the host's title projection, falling back to the session ID. */
11
- export function sessionLabel(session) {
12
- const projections = session.projections;
13
- if (projections && typeof projections === 'object' && !Array.isArray(projections)) {
14
- const values = projections.values;
15
- const title = values && typeof values === 'object' && !Array.isArray(values) ? values.title : undefined;
16
- if (typeof title === 'string' && safeText(title).trim())
17
- return safeText(title).trim();
18
- if (title && typeof title === 'object' && !Array.isArray(title) && typeof title.title === 'string' && safeText(title.title).trim())
19
- return safeText(title.title).trim();
20
- }
21
- return string(session.sessionId);
22
- }
1
+ /** Unambiguous slash-command target resolution and host title projection. */
2
+ import { string } from "../json.js";
23
3
  /** Match an exact ID or name before a unique ID prefix; never choose an ambiguous target. */
24
4
  export function resolveTarget(items, query, id, names) {
25
5
  const target = query.replace(/^(["'])(.*)\1$/, '$2');
@@ -34,110 +14,3 @@ export function resolveTarget(items, query, id, names) {
34
14
  throw new Error(`Ambiguous target: ${target}. Use a full ID.`);
35
15
  throw new Error(`Target not found: ${target}`);
36
16
  }
37
- /** Classify one session from the host's list summary; nothing is inferred from silence.
38
- * @param session - Session summary from `session/list`.
39
- * @param pending - Whether this client holds an unanswered interaction for that session.
40
- * @returns Needs-you while an answer is owed, running while its agent works, otherwise idle or blank.
41
- */
42
- export function sessionState(session, pending = false) {
43
- if (pending)
44
- return 'needs';
45
- if (session.running === true)
46
- return 'running';
47
- return session.blank === true ? 'blank' : 'idle';
48
- }
49
- /** Leading marker per state: a question mark, a working clock, a filled dot, and an unused circle. */
50
- export const SESSION_MARKERS = { needs: '?', running: '◐', idle: '●', blank: '○' };
51
- /** One word per state, so every screen names the same state the same way. */
52
- export const STATE_LABELS = { needs: 'needs you', running: 'working', idle: 'ready', blank: 'empty' };
53
- /** Coarse age of a session's last activity, so the column stays steady between list refreshes.
54
- * @param time - Epoch milliseconds of the last activity, when the summary reported one.
55
- * @param now - Current epoch milliseconds.
56
- * @returns `now`, minutes, hours or days.
57
- */
58
- export function activityAge(time, now) {
59
- if (time === undefined || !Number.isFinite(time))
60
- return '';
61
- const seconds = Math.max(0, Math.floor((now - time) / 1000));
62
- if (seconds < 60)
63
- return 'now';
64
- const minutes = Math.floor(seconds / 60);
65
- if (minutes < 60)
66
- return `${minutes}m`;
67
- const hours = Math.floor(minutes / 60);
68
- return hours < 24 ? `${hours}h` : `${Math.floor(hours / 24)}d`;
69
- }
70
- /** Status cell for one session row: its state marker and the age of its last activity.
71
- * @param session - Session summary from `session/list`.
72
- * @param now - Current epoch milliseconds.
73
- * @param pending - Whether this client holds an unanswered interaction for that session.
74
- * @returns Marker with an optional age, without a trailing space when unknown.
75
- */
76
- export function sessionStatus(session, now, pending = false) {
77
- const age = activityAge(typeof session.updatedAt === 'number' ? session.updatedAt : undefined, now);
78
- return `${SESSION_MARKERS[sessionState(session, pending)]}${age === '' ? '' : ` ${age}`}`;
79
- }
80
- /** States a workspace rollup reports, most actionable first. */
81
- export const ROLLUP_STATES = ['needs', 'running', 'idle'];
82
- /** Count the sessions of one workspace by the state each reports.
83
- *
84
- * Blank sessions are counted by neither a badge nor a word: a session that never sent a turn is the
85
- * absence of activity, and listing it beside real work only makes the rollup harder to read.
86
- * @param sessions - Sessions whose `sessionIds` belong to the workspace.
87
- * @param pending - Session IDs this client holds an unanswered interaction for.
88
- * @returns One count per state that occurs, most actionable first, or an empty list.
89
- */
90
- export function workspaceCounts(sessions, pending = new Set()) {
91
- const counts = { needs: 0, running: 0, idle: 0 };
92
- for (const session of sessions) {
93
- const id = session.sessionId;
94
- const state = sessionState(session, typeof id === 'string' && pending.has(id));
95
- if (state !== 'blank')
96
- counts[state] += 1;
97
- }
98
- return ROLLUP_STATES.filter(state => counts[state] > 0).map(state => ({ state, count: counts[state] }));
99
- }
100
- /** Render one rollup as separately coloured cells.
101
- *
102
- * Each cell after the first carries the separator that joins it to the previous one, so a caller can
103
- * colour the cells independently without losing the text {@link workspaceStatus} would produce.
104
- * @param counts - Counts from {@link workspaceCounts}.
105
- * @param style - `words` spells each state out; `badges` keeps only the marker and the count.
106
- * @returns The cells in the order given, with their separators.
107
- */
108
- export function workspaceSegments(counts, style = 'words') {
109
- const separator = style === 'words' ? ' · ' : ' ';
110
- return counts.map(({ state, count }, index) => ({ state,
111
- text: `${index === 0 ? '' : separator}${style === 'words' ? `${SESSION_MARKERS[state]} ${count} ${STATE_LABELS[state]}` : `${SESSION_MARKERS[state]}${count}`}` }));
112
- }
113
- /** Render one rollup as plain text, the same way every screen and test reads it.
114
- * @param counts - Counts from {@link workspaceCounts}.
115
- * @param style - `words` spells each state out; `badges` keeps only the marker and the count.
116
- * @returns The joined cell text, empty when nothing was counted.
117
- */
118
- export function workspaceStatus(counts, style = 'words') {
119
- return workspaceSegments(counts, style).map(segment => segment.text).join('');
120
- }
121
- /** Marker key for the compact rollup, which has no room for the words. */
122
- export const ROLLUP_LEGEND = `${SESSION_MARKERS.idle} ${STATE_LABELS.idle} · ${SESSION_MARKERS.running} ${STATE_LABELS.running} · ${SESSION_MARKERS.needs} ${STATE_LABELS.needs}`;
123
- /** Secondary path text for one workspace row.
124
- *
125
- * The title is usually the last path segment, so repeating it wastes the row; the parent directory
126
- * is what distinguishes two checkouts. A title that does not name the last segment keeps the full
127
- * path, because dropping it would hide where the workspace actually lives.
128
- * @param path - Registered host directory.
129
- * @param title - Workspace title as the row already shows it.
130
- * @returns The path to show beside the row, or an empty string when nothing is left.
131
- */
132
- export function workspaceDetail(path, title) {
133
- if (!path)
134
- return '';
135
- const trimmed = path.replace(/\/+$/u, '');
136
- const cut = trimmed.lastIndexOf('/');
137
- const base = cut < 0 ? trimmed : trimmed.slice(cut + 1);
138
- if (base !== title)
139
- return path;
140
- if (cut > 0)
141
- return trimmed.slice(0, cut);
142
- return cut === 0 ? '/' : '';
143
- }
@@ -0,0 +1,38 @@
1
+ import type { HostAccess } from '../transport/host.ts';
2
+ import type { ObjectValue } from '../transport/wire.ts';
3
+ import { Transcript } from './transcript.ts';
4
+ /** One followed session's read-only state. */
5
+ export interface PeekState {
6
+ /** The transcript as the host has streamed it. */
7
+ readonly transcript: Transcript;
8
+ /** True once the stream ended, so a view stops calling it live. */
9
+ readonly ended: boolean;
10
+ /** Set when no address form could be followed. */
11
+ readonly error?: string;
12
+ }
13
+ /** Follows one session address form at a time, read-only. */
14
+ export declare class SessionPeek {
15
+ private readonly host;
16
+ private subscription;
17
+ private state;
18
+ /** Address forms left to try, in order; a host may refuse the child form for one delivery mode. */
19
+ private pending;
20
+ /** Bumped on every open and close, so a cancelled stream's late frame cannot revive the state. */
21
+ private generation;
22
+ /** @param host - Transport access this client already holds; the peek borrows it, never owns it. */
23
+ constructor(host: HostAccess);
24
+ /** Start following the first address that answers, releasing whatever was followed before.
25
+ * @param addresses - Address forms to try in order.
26
+ * @param onChange - Called when the transcript or the state moves.
27
+ */
28
+ open(addresses: readonly ObjectValue[], onChange: () => void): void;
29
+ /** Stop following and release the transcript; safe to call when nothing is open. */
30
+ close(): void;
31
+ /** @returns What a view renders, or undefined when nothing is followed. */
32
+ get snapshot(): PeekState | undefined;
33
+ /** Follow the next address form, or report that none worked.
34
+ * @param generation - The open this attempt belongs to; a superseded one stops silently.
35
+ * @param onChange - Called when the transcript or the state moves.
36
+ */
37
+ private next;
38
+ }
@@ -0,0 +1,103 @@
1
+ import { releaseHistoryLayout } from "./history.js";
2
+ import { Transcript } from "./transcript.js";
3
+ /** How much of a followed session the first snapshot asks for; the live tail appends after it. */
4
+ const PEEK_MESSAGES = 120;
5
+ /** Follows one session address form at a time, read-only. */
6
+ export class SessionPeek {
7
+ host;
8
+ subscription;
9
+ state;
10
+ /** Address forms left to try, in order; a host may refuse the child form for one delivery mode. */
11
+ pending = [];
12
+ /** Bumped on every open and close, so a cancelled stream's late frame cannot revive the state. */
13
+ generation = 0;
14
+ /** @param host - Transport access this client already holds; the peek borrows it, never owns it. */
15
+ constructor(host) {
16
+ this.host = host;
17
+ }
18
+ /** Start following the first address that answers, releasing whatever was followed before.
19
+ * @param addresses - Address forms to try in order.
20
+ * @param onChange - Called when the transcript or the state moves.
21
+ */
22
+ open(addresses, onChange) {
23
+ this.close();
24
+ // `close` bumped the generation; this open owns the next one and every callback checks it.
25
+ const generation = ++this.generation;
26
+ this.pending = [...addresses];
27
+ this.next(generation, onChange);
28
+ }
29
+ /** Stop following and release the transcript; safe to call when nothing is open. */
30
+ close() {
31
+ this.generation += 1;
32
+ this.subscription?.cancel();
33
+ this.subscription = undefined;
34
+ this.pending = [];
35
+ if (this.state === undefined)
36
+ return;
37
+ releaseHistoryLayout(this.state.transcript);
38
+ this.state.transcript.dispose();
39
+ this.state = undefined;
40
+ }
41
+ /** @returns What a view renders, or undefined when nothing is followed. */
42
+ get snapshot() { return this.state; }
43
+ /** Follow the next address form, or report that none worked.
44
+ * @param generation - The open this attempt belongs to; a superseded one stops silently.
45
+ * @param onChange - Called when the transcript or the state moves.
46
+ */
47
+ next(generation, onChange) {
48
+ if (generation !== this.generation)
49
+ return;
50
+ const address = this.pending.shift();
51
+ if (address === undefined) {
52
+ this.state = { transcript: new Transcript(), ended: true, error: 'the host refused every address form' };
53
+ onChange();
54
+ return;
55
+ }
56
+ const transcript = new Transcript();
57
+ this.state = { transcript, ended: false };
58
+ let attempted = false;
59
+ let subscription;
60
+ try {
61
+ subscription = this.host.require().subscribe('session/follow', {
62
+ request: { address, maxMessages: PEEK_MESSAGES, assistantStream: true },
63
+ }, {
64
+ item: value => {
65
+ if (generation !== this.generation)
66
+ return;
67
+ // A peek is a diagnostic surface: a malformed frame ends it with a reason rather than taking
68
+ // the whole client down the way a selected session's stream would.
69
+ try {
70
+ transcript.accept(value);
71
+ }
72
+ catch {
73
+ this.state = { transcript, ended: true, error: 'the session stream sent a frame this client cannot read' };
74
+ }
75
+ onChange();
76
+ },
77
+ end: error => {
78
+ // A cancelled stream still reports its end; only the live attempt may act on it.
79
+ if (attempted || generation !== this.generation)
80
+ return;
81
+ attempted = true;
82
+ if (error !== undefined && this.pending.length > 0) {
83
+ this.next(generation, onChange);
84
+ return;
85
+ }
86
+ this.state = { transcript, ended: true, ...(error === undefined ? {} : { error: errorTextOf(error) }) };
87
+ onChange();
88
+ },
89
+ });
90
+ }
91
+ catch (error) {
92
+ // Offline, or between connection generations: the view says so instead of failing the caller.
93
+ this.state = { transcript, ended: true, error: errorTextOf(error) };
94
+ onChange();
95
+ return;
96
+ }
97
+ this.subscription = subscription;
98
+ }
99
+ }
100
+ /** One error as displayable text, without importing the whole wire module. */
101
+ function errorTextOf(error) {
102
+ return error instanceof Error ? error.message : String(error);
103
+ }
@@ -1,23 +1,5 @@
1
- /** A path relative to the remote session's working directory. */
2
- export interface FileReference {
3
- path: string;
4
- kind: 'file' | 'directory';
5
- }
6
- /** Find the unfinished reference at the end of the draft; email addresses do not trigger it.
7
- * @param text - Complete composer draft.
8
- * @returns The replaceable token and host query, or undefined outside a reference.
9
- */
10
- export declare function activeReference(text: string): {
11
- prefix: string;
12
- query: string;
13
- quoted: boolean;
14
- } | undefined;
15
- /** Encode a candidate in Harness prompt syntax; directories keep completion open.
16
- * @param candidate - Remote path and entry kind.
17
- * @param quoted - Preserve an explicitly opened quote.
18
- * @returns Mention text, or undefined for paths the mention grammar cannot encode.
19
- */
20
- export declare function fileMention(candidate: FileReference, quoted?: boolean): string | undefined;
1
+ import { type FileReference } from '../references.ts';
2
+ export type { FileReference } from '../references.ts';
21
3
  /** Validate remote candidates and omit paths that cannot be inserted safely.
22
4
  * @param value - Decoded fileReferences/list result.
23
5
  * @returns Ordered file and directory candidates.
@@ -1,31 +1,6 @@
1
1
  /** Independent adapter for Harness path-only @ references; no file bytes are read. */
2
2
  import { array, object, string } from "../transport/wire.js";
3
- /** Find the unfinished reference at the end of the draft; email addresses do not trigger it.
4
- * @param text - Complete composer draft.
5
- * @returns The replaceable token and host query, or undefined outside a reference.
6
- */
7
- export function activeReference(text) {
8
- const quoted = /(?:^|\s)(@"([^"]*))$/u.exec(text);
9
- if (quoted)
10
- return { prefix: quoted[1], query: quoted[2], quoted: true };
11
- const plain = /(?:^|\s)(@([^\s"]*))$/u.exec(text);
12
- if (plain)
13
- return { prefix: plain[1], query: plain[2], quoted: false };
14
- return undefined;
15
- }
16
- /** Encode a candidate in Harness prompt syntax; directories keep completion open.
17
- * @param candidate - Remote path and entry kind.
18
- * @param quoted - Preserve an explicitly opened quote.
19
- * @returns Mention text, or undefined for paths the mention grammar cannot encode.
20
- */
21
- export function fileMention(candidate, quoted = false) {
22
- const path = candidate.path + (candidate.kind === 'directory' ? '/' : '');
23
- if (/[\u0000-\u001f\u007f-\u009f"]/u.test(path))
24
- return undefined;
25
- if (!quoted && !/\s/u.test(path))
26
- return `@${path}`;
27
- return `@"${path}${candidate.kind === 'file' ? '"' : ''}`;
28
- }
3
+ import { fileMention } from "../references.js";
29
4
  /** Validate remote candidates and omit paths that cannot be inserted safely.
30
5
  * @param value - Decoded fileReferences/list result.
31
6
  * @returns Ordered file and directory candidates.
@@ -0,0 +1,26 @@
1
+ /** Per-session host runtime mirrors that are not connection state.
2
+ *
3
+ * `running` and the observation start change while the network stays perfectly healthy
4
+ * (`ready → running → waiting → running`), so they belong to the session runtime rather than to the
5
+ * connection. Both maps are keyed by `sessionId` and cleared at each connection generation.
6
+ */
7
+ import type { ControlFrame } from '../transport/events.ts';
8
+ import { Telemetry } from './telemetry.ts';
9
+ export declare class SessionRuntime {
10
+ /** Host projection values for every session of the current generation. */
11
+ telemetry: Telemetry;
12
+ private readonly running;
13
+ private readonly observed;
14
+ /** Cached host running flag for one session, or undefined when never reported. */
15
+ runningFor(sessionId: string): boolean | undefined;
16
+ /** When this client first observed the session running, for the elapsed-time fallback. */
17
+ observedAt(sessionId: string): number | undefined;
18
+ /** Remember when a session this client just opened was first seen. */
19
+ observe(sessionId: string): void;
20
+ /** Apply one host running-state notification for a session. */
21
+ accept(sessionId: string, running: boolean): void;
22
+ /** Apply one normalized projection/queue/job frame. */
23
+ acceptControl(frame: ControlFrame): void;
24
+ /** Drop every generation-scoped mirror, including the projection store. */
25
+ reset(): void;
26
+ }
@@ -0,0 +1,28 @@
1
+ import { Telemetry } from "./telemetry.js";
2
+ /** Projection capabilities this client consumes; the host retains every other key. */
3
+ const RETAINED_PROJECTIONS = new Set(['title', 'modelSelection', 'contextPressure', 'tokenUsage', 'sessionStats', 'agentPreset']);
4
+ export class SessionRuntime {
5
+ /** Host projection values for every session of the current generation. */
6
+ telemetry = new Telemetry(RETAINED_PROJECTIONS);
7
+ running = new Map();
8
+ observed = new Map();
9
+ /** Cached host running flag for one session, or undefined when never reported. */
10
+ runningFor(sessionId) { return this.running.get(sessionId); }
11
+ /** When this client first observed the session running, for the elapsed-time fallback. */
12
+ observedAt(sessionId) { return this.observed.get(sessionId); }
13
+ /** Remember when a session this client just opened was first seen. */
14
+ observe(sessionId) { if (!this.observed.has(sessionId))
15
+ this.observed.set(sessionId, Date.now()); }
16
+ /** Apply one host running-state notification for a session. */
17
+ accept(sessionId, running) {
18
+ if (running && !this.running.get(sessionId))
19
+ this.observed.set(sessionId, Date.now());
20
+ if (!running)
21
+ this.observed.delete(sessionId);
22
+ this.running.set(sessionId, running);
23
+ }
24
+ /** Apply one normalized projection/queue/job frame. */
25
+ acceptControl(frame) { this.telemetry.accept(frame); }
26
+ /** Drop every generation-scoped mirror, including the projection store. */
27
+ reset() { this.running.clear(); this.observed.clear(); this.telemetry = new Telemetry(RETAINED_PROJECTIONS); }
28
+ }
@@ -1,11 +1,10 @@
1
- /** Host projection values with per-key watermarks; snapshots never roll back newer updates. */
2
- import { type ObjectValue } from '../transport/wire.ts';
3
- /** One host-owned pending input, removable only while its occurrence remains queued. */
4
- export interface QueuedInput {
5
- id: string;
6
- placement: 'queued' | 'steering' | 'context';
7
- text: string;
8
- }
1
+ /** Host projection values with per-key watermarks; snapshots never roll back newer updates.
2
+ *
3
+ * The wire shape is decoded by `transport/events.ts`; this class only applies semantic control frames
4
+ * to its own maps, so a host field rename never reaches the session domain.
5
+ */
6
+ import type { ControlFrame, ProjectionSnapshot, ProjectionValue, QueuedInput } from '../transport/events.ts';
7
+ export type { QueuedInput } from '../transport/events.ts';
9
8
  /** Generation-local projection, inbox and background-job data for every session. */
10
9
  export declare class Telemetry {
11
10
  private readonly retainedKeys?;
@@ -16,20 +15,20 @@ export declare class Telemetry {
16
15
  /** Optionally retain only projection capabilities consumed by this client. */
17
16
  constructor(retainedKeys?: ReadonlySet<string> | undefined);
18
17
  /** Replace all control state on a new stream baseline, then accept replacement frames.
19
- * @param value - One decoded session/control frame.
18
+ * @param frame - One normalized session/control frame.
20
19
  */
21
- accept(value: unknown): void;
20
+ accept(frame: ControlFrame): void;
22
21
  /** Merge a complete follow snapshot without restoring absent or older projection values.
23
22
  * @param id - Session identity.
24
- * @param value - Projection baseline, when supplied by the host.
23
+ * @param baseline - Projection baseline, when supplied by the host.
25
24
  */
26
- snapshot(id: string, value: unknown): void;
25
+ snapshot(id: string, baseline: ProjectionSnapshot | undefined): void;
27
26
  /** Read current values for one session; missing capabilities remain absent.
28
27
  * @param id - Selected session identity, if any.
29
28
  * @returns Projection values and known queue/job counts.
30
29
  */
31
30
  view(id?: string): {
32
- values: ObjectValue;
31
+ values: Readonly<Record<string, ProjectionValue>>;
33
32
  queued?: number;
34
33
  jobs?: number;
35
34
  };
@@ -1,14 +1,3 @@
1
- /** Host projection values with per-key watermarks; snapshots never roll back newer updates. */
2
- import { array, object, string } from "../transport/wire.js";
3
- function queuedInputs(value) {
4
- return array(value).map(value => {
5
- const item = object(value);
6
- if (!['queued', 'steering', 'context'].includes(string(item.placement)))
7
- throw new Error('Invalid queue placement');
8
- return { id: string(item.id), placement: item.placement,
9
- text: array(object(item.message).content).map(object).map(block => block.type === 'text' ? string(block.text) : `[${string(block.type)}]`).join(' ') };
10
- });
11
- }
12
1
  /** Generation-local projection, inbox and background-job data for every session. */
13
2
  export class Telemetry {
14
3
  retainedKeys;
@@ -21,72 +10,60 @@ export class Telemetry {
21
10
  this.retainedKeys = retainedKeys;
22
11
  }
23
12
  /** Replace all control state on a new stream baseline, then accept replacement frames.
24
- * @param value - One decoded session/control frame.
13
+ * @param frame - One normalized session/control frame.
25
14
  */
26
- accept(value) {
27
- const frame = object(value);
28
- if (frame.type === 'baseline') {
29
- const baseline = object(frame.value);
15
+ accept(frame) {
16
+ if (frame.kind === 'baseline') {
30
17
  this.entries.clear();
31
18
  this.queues.clear();
32
19
  this.jobs.clear();
33
- for (const [id, projection] of Object.entries(object(baseline.projections)))
34
- this.snapshot(id, projection);
35
- for (const [id, items] of Object.entries(object(baseline.queues)))
36
- this.queues.set(id, queuedInputs(items));
37
- for (const [id, items] of Object.entries(object(baseline.jobs)))
38
- this.jobs.set(id, activeJobs(items));
20
+ for (const [id, snapshot] of frame.projections)
21
+ this.snapshot(id, snapshot);
22
+ for (const [id, items] of frame.queues)
23
+ this.queues.set(id, [...items]);
24
+ for (const [id, count] of frame.jobs)
25
+ this.jobs.set(id, count);
39
26
  this.ready = true;
40
27
  return;
41
28
  }
42
29
  if (!this.ready)
43
30
  throw new Error('Session control update before baseline');
44
- const id = string(frame.sessionId);
45
- if (frame.type === 'projection') {
46
- const entry = this.entry(id);
47
- const key = string(frame.key);
48
- const seq = sequence(frame.seq);
49
- if (seq < (entry.revisions.get(key) ?? entry.baseline))
31
+ if (frame.kind === 'projection') {
32
+ const entry = this.entry(frame.sessionId);
33
+ if (frame.seq < (entry.revisions.get(frame.key) ?? entry.baseline))
50
34
  return;
51
- if (frame.value === undefined)
52
- throw new Error('Missing projection value');
53
- if (this.retainedKeys && !this.retainedKeys.has(key))
35
+ if (this.retainedKeys && !this.retainedKeys.has(frame.key))
54
36
  return;
55
- entry.values[key] = frame.value;
56
- entry.revisions.set(key, seq);
37
+ entry.values[frame.key] = frame.value;
38
+ entry.revisions.set(frame.key, frame.seq);
57
39
  }
58
- else if (frame.type === 'queue')
59
- this.queues.set(id, queuedInputs(frame.items));
60
- else if (frame.type === 'jobs')
61
- this.jobs.set(id, activeJobs(frame.items));
40
+ else if (frame.kind === 'queue')
41
+ this.queues.set(frame.sessionId, [...frame.items]);
62
42
  else
63
- throw new Error('Unknown session control frame');
43
+ this.jobs.set(frame.sessionId, frame.count);
64
44
  }
65
45
  /** Merge a complete follow snapshot without restoring absent or older projection values.
66
46
  * @param id - Session identity.
67
- * @param value - Projection baseline, when supplied by the host.
47
+ * @param baseline - Projection baseline, when supplied by the host.
68
48
  */
69
- snapshot(id, value) {
70
- if (value === undefined)
49
+ snapshot(id, baseline) {
50
+ if (baseline === undefined)
71
51
  return;
72
- const baseline = object(value);
73
- const seq = sequence(baseline.asOfSeq);
74
- const values = object(baseline.values);
75
52
  const entry = this.entry(id);
76
- if (seq < entry.baseline)
53
+ if (baseline.asOfSeq < entry.baseline)
77
54
  return;
78
- for (const key of new Set([...Object.keys(entry.values), ...Object.keys(values)])) {
55
+ for (const key of new Set([...Object.keys(entry.values), ...Object.keys(baseline.values)])) {
79
56
  if (this.retainedKeys && !this.retainedKeys.has(key))
80
57
  continue;
81
- if ((entry.revisions.get(key) ?? -1) > seq)
58
+ if ((entry.revisions.get(key) ?? -1) > baseline.asOfSeq)
82
59
  continue;
83
- if (Object.hasOwn(values, key))
84
- entry.values[key] = values[key];
60
+ if (Object.hasOwn(baseline.values, key))
61
+ entry.values[key] = baseline.values[key];
85
62
  else
86
63
  delete entry.values[key];
87
64
  entry.revisions.delete(key);
88
65
  }
89
- entry.baseline = seq;
66
+ entry.baseline = baseline.asOfSeq;
90
67
  }
91
68
  /** Read current values for one session; missing capabilities remain absent.
92
69
  * @param id - Selected session identity, if any.
@@ -110,11 +87,3 @@ export class Telemetry {
110
87
  return entry;
111
88
  }
112
89
  }
113
- function sequence(value) {
114
- if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < -1)
115
- throw new Error('Invalid projection watermark');
116
- return value;
117
- }
118
- function activeJobs(value) {
119
- return array(value).filter(item => ['running', 'stopping'].includes(string(object(item).status))).length;
120
- }
@@ -6,12 +6,6 @@ interface ToolSummary {
6
6
  operation?: string;
7
7
  command?: string;
8
8
  }
9
- /** Fit a tool operation to one terminal row without exposing the result body.
10
- * @param text - Tool name, status icon and optional operation.
11
- * @param width - Available terminal columns.
12
- * @returns A single line with an ellipsis when shortened.
13
- */
14
- export declare function toolLine(text: string, width: number): string;
15
9
  /** Render known content blocks and preserve unknown plugin blocks as JSON.
16
10
  * @param content - Message content blocks.
17
11
  * @param tools - Tool summaries by call ID, when retained history provides them.
@@ -1,6 +1,5 @@
1
- /** Human transcript projection from durable events and ephemeral assistant chunks. */
2
- import sliceAnsi from 'slice-ansi';
3
- import { array, object, safeText, string } from "../transport/wire.js";
1
+ import { array, object, string } from "../transport/wire.js";
2
+ import { safeText, toolLine } from "../text.js";
4
3
  function toolSummary(block) {
5
4
  let args = {};
6
5
  if (typeof block.arguments === 'string' && block.arguments.trimStart().startsWith('{')) {
@@ -19,18 +18,6 @@ function toolSummary(block) {
19
18
  return { name: string(block.name), ...(typeof operation === 'string' ? { operation } : {}),
20
19
  ...(typeof command === 'string' && command !== operation ? { command: command.split(/\r?\n/)[0] } : {}) };
21
20
  }
22
- /** Fit a tool operation to one terminal row without exposing the result body.
23
- * @param text - Tool name, status icon and optional operation.
24
- * @param width - Available terminal columns.
25
- * @returns A single line with an ellipsis when shortened.
26
- */
27
- export function toolLine(text, width) {
28
- const clean = safeText(text).replace(/\s+/gu, ' ').trim();
29
- if (width < 2)
30
- return width === 1 ? '…' : '';
31
- const clipped = sliceAnsi(clean, 0, width);
32
- return clipped.length < clean.length ? sliceAnsi(clean, 0, width - 1) + '…' : clean;
33
- }
34
21
  /** Render known content blocks and preserve unknown plugin blocks as JSON.
35
22
  * @param content - Message content blocks.
36
23
  * @param tools - Tool summaries by call ID, when retained history provides them.
@@ -1,4 +1,5 @@
1
1
  /** Session-domain result types shared by the controller facade and the terminal UI. */
2
+ import type { QuestionItem } from '../transport/events.ts';
2
3
  /** Resolved navigation-removal identity; empty marks a fresh blank, idle session eligible for immediate archival. */
3
4
  export interface RemovalTarget {
4
5
  kind: 'workspace' | 'session';
@@ -16,3 +17,27 @@ export interface HistorySearch {
16
17
  }[];
17
18
  truncated: boolean;
18
19
  }
20
+ /** A structured question answer as the UI collects it; the host receives it inside an outcome. */
21
+ export type AnswerValue = {
22
+ answers: {
23
+ id: string;
24
+ selected: string[];
25
+ custom?: string;
26
+ }[];
27
+ };
28
+ /** One unanswered host interaction of the selected session.
29
+ *
30
+ * A discriminated union rather than the raw waterfall: the UI switches on `kind` and reads named
31
+ * fields, so it never needs to know how the host shaped its request.
32
+ */
33
+ export type PendingInteraction = {
34
+ kind: 'approval';
35
+ eventId: string;
36
+ sessionId: string;
37
+ description: string;
38
+ } | {
39
+ kind: 'question';
40
+ eventId: string;
41
+ sessionId: string;
42
+ questions: readonly QuestionItem[];
43
+ };
@@ -1,2 +1 @@
1
- /** Session-domain result types shared by the controller facade and the terminal UI. */
2
1
  export {};