@jmoyers/harness 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +145 -0
- package/native/ptyd/Cargo.lock +16 -0
- package/native/ptyd/Cargo.toml +7 -0
- package/native/ptyd/src/main.rs +257 -0
- package/package.json +90 -0
- package/scripts/build-ptyd.sh +73 -0
- package/scripts/control-plane-daemon.ts +277 -0
- package/scripts/cursor-hook-relay.ts +82 -0
- package/scripts/harness-animate.ts +469 -0
- package/scripts/harness-bin.js +77 -0
- package/scripts/harness-core.ts +1 -0
- package/scripts/harness-inspector.ts +439 -0
- package/scripts/harness.ts +2493 -0
- package/src/adapters/agent-session-state.ts +390 -0
- package/src/cli/gateway-record.ts +173 -0
- package/src/codex/live-session.ts +872 -0
- package/src/config/config-core.ts +1359 -0
- package/src/config/secrets-core.ts +170 -0
- package/src/control-plane/agent-realtime-api.ts +2441 -0
- package/src/control-plane/codex-session-stream.ts +392 -0
- package/src/control-plane/codex-telemetry.ts +1325 -0
- package/src/control-plane/lifecycle-hooks.ts +706 -0
- package/src/control-plane/session-summary.ts +380 -0
- package/src/control-plane/status/agent-status-reducer.ts +21 -0
- package/src/control-plane/status/reducer-base.ts +170 -0
- package/src/control-plane/status/reducers/claude-status-reducer.ts +37 -0
- package/src/control-plane/status/reducers/codex-status-reducer.ts +48 -0
- package/src/control-plane/status/reducers/critique-status-reducer.ts +15 -0
- package/src/control-plane/status/reducers/cursor-status-reducer.ts +37 -0
- package/src/control-plane/status/reducers/terminal-status-reducer.ts +15 -0
- package/src/control-plane/status/session-status-engine.ts +76 -0
- package/src/control-plane/stream-client.ts +396 -0
- package/src/control-plane/stream-command-parser.ts +1673 -0
- package/src/control-plane/stream-protocol.ts +1808 -0
- package/src/control-plane/stream-server-background.ts +486 -0
- package/src/control-plane/stream-server-command.ts +2557 -0
- package/src/control-plane/stream-server-connection.ts +234 -0
- package/src/control-plane/stream-server-observed-filter.ts +112 -0
- package/src/control-plane/stream-server-session-runtime.ts +566 -0
- package/src/control-plane/stream-server-state-store.ts +15 -0
- package/src/control-plane/stream-server.ts +3192 -0
- package/src/cursor/managed-hooks.ts +282 -0
- package/src/domain/conversations.ts +414 -0
- package/src/domain/directories.ts +78 -0
- package/src/domain/repositories.ts +123 -0
- package/src/domain/tasks.ts +148 -0
- package/src/domain/workspace.ts +156 -0
- package/src/events/normalized-events.ts +124 -0
- package/src/mux/ansi-integrity.ts +103 -0
- package/src/mux/control-plane-op-queue.ts +212 -0
- package/src/mux/conversation-rail.ts +339 -0
- package/src/mux/double-click.ts +78 -0
- package/src/mux/dual-pane-core.ts +435 -0
- package/src/mux/harness-core-ui.ts +817 -0
- package/src/mux/input-shortcuts.ts +667 -0
- package/src/mux/live-mux/actions-conversation.ts +344 -0
- package/src/mux/live-mux/actions-repository.ts +246 -0
- package/src/mux/live-mux/actions-task.ts +115 -0
- package/src/mux/live-mux/args.ts +142 -0
- package/src/mux/live-mux/command-menu.ts +298 -0
- package/src/mux/live-mux/control-plane-records.ts +546 -0
- package/src/mux/live-mux/conversation-state.ts +188 -0
- package/src/mux/live-mux/directory-resolution.ts +34 -0
- package/src/mux/live-mux/event-mapping.ts +96 -0
- package/src/mux/live-mux/gateway-profiler.ts +152 -0
- package/src/mux/live-mux/gateway-render-trace.ts +177 -0
- package/src/mux/live-mux/gateway-status-timeline.ts +166 -0
- package/src/mux/live-mux/git-parsing.ts +131 -0
- package/src/mux/live-mux/git-snapshot.ts +263 -0
- package/src/mux/live-mux/git-state.ts +136 -0
- package/src/mux/live-mux/global-shortcut-handlers.ts +143 -0
- package/src/mux/live-mux/home-pane-actions.ts +58 -0
- package/src/mux/live-mux/home-pane-drop.ts +44 -0
- package/src/mux/live-mux/home-pane-entity-click.ts +96 -0
- package/src/mux/live-mux/home-pane-pointer.ts +96 -0
- package/src/mux/live-mux/input-forwarding.ts +112 -0
- package/src/mux/live-mux/layout.ts +30 -0
- package/src/mux/live-mux/left-nav-activation.ts +103 -0
- package/src/mux/live-mux/left-nav.ts +85 -0
- package/src/mux/live-mux/left-rail-actions.ts +118 -0
- package/src/mux/live-mux/left-rail-conversation-click.ts +82 -0
- package/src/mux/live-mux/left-rail-pointer.ts +74 -0
- package/src/mux/live-mux/modal-command-menu-handler.ts +101 -0
- package/src/mux/live-mux/modal-conversation-handlers.ts +217 -0
- package/src/mux/live-mux/modal-input-reducers.ts +94 -0
- package/src/mux/live-mux/modal-overlays.ts +287 -0
- package/src/mux/live-mux/modal-pointer.ts +70 -0
- package/src/mux/live-mux/modal-prompt-handlers.ts +187 -0
- package/src/mux/live-mux/modal-task-editor-handler.ts +156 -0
- package/src/mux/live-mux/observed-stream.ts +87 -0
- package/src/mux/live-mux/palette-parsing.ts +128 -0
- package/src/mux/live-mux/pointer-routing.ts +108 -0
- package/src/mux/live-mux/process-usage.ts +53 -0
- package/src/mux/live-mux/project-pane-pointer.ts +44 -0
- package/src/mux/live-mux/rail-layout.ts +244 -0
- package/src/mux/live-mux/render-trace-analysis.ts +213 -0
- package/src/mux/live-mux/render-trace-state.ts +84 -0
- package/src/mux/live-mux/repository-folding.ts +207 -0
- package/src/mux/live-mux/runtime-shutdown.ts +51 -0
- package/src/mux/live-mux/selection.ts +411 -0
- package/src/mux/live-mux/startup-utils.ts +187 -0
- package/src/mux/live-mux/status-timeline-state.ts +82 -0
- package/src/mux/live-mux/task-pane-shortcuts.ts +206 -0
- package/src/mux/live-mux/terminal-palette.ts +79 -0
- package/src/mux/new-thread-prompt.ts +165 -0
- package/src/mux/project-tree.ts +295 -0
- package/src/mux/render-frame.ts +113 -0
- package/src/mux/runtime-wiring.ts +185 -0
- package/src/mux/selector-index.ts +160 -0
- package/src/mux/startup-sequencer.ts +238 -0
- package/src/mux/task-composer.ts +289 -0
- package/src/mux/task-focused-pane.ts +417 -0
- package/src/mux/task-screen-keybindings.ts +539 -0
- package/src/mux/terminal-input-modes.ts +35 -0
- package/src/mux/workspace-path.ts +55 -0
- package/src/mux/workspace-rail-model.ts +701 -0
- package/src/mux/workspace-rail.ts +247 -0
- package/src/perf/perf-core.ts +307 -0
- package/src/pty/pty_host.ts +217 -0
- package/src/pty/session-broker.ts +158 -0
- package/src/recording/terminal-recording.ts +383 -0
- package/src/services/control-plane.ts +567 -0
- package/src/services/conversation-lifecycle.ts +176 -0
- package/src/services/conversation-startup-hydration.ts +47 -0
- package/src/services/directory-hydration.ts +49 -0
- package/src/services/event-persistence.ts +104 -0
- package/src/services/mux-ui-state-persistence.ts +82 -0
- package/src/services/output-load-sampler.ts +231 -0
- package/src/services/process-usage-refresh.ts +88 -0
- package/src/services/recording.ts +75 -0
- package/src/services/render-trace-recorder.ts +177 -0
- package/src/services/runtime-control-actions.ts +123 -0
- package/src/services/runtime-control-plane-ops.ts +131 -0
- package/src/services/runtime-conversation-actions.ts +113 -0
- package/src/services/runtime-conversation-activation.ts +78 -0
- package/src/services/runtime-conversation-starter.ts +171 -0
- package/src/services/runtime-conversation-title-edit.ts +149 -0
- package/src/services/runtime-directory-actions.ts +164 -0
- package/src/services/runtime-envelope-handler.ts +198 -0
- package/src/services/runtime-git-state.ts +92 -0
- package/src/services/runtime-input-pipeline.ts +50 -0
- package/src/services/runtime-input-router.ts +202 -0
- package/src/services/runtime-layout-resize.ts +236 -0
- package/src/services/runtime-left-rail-render.ts +159 -0
- package/src/services/runtime-main-pane-input.ts +230 -0
- package/src/services/runtime-modal-input.ts +119 -0
- package/src/services/runtime-navigation-input.ts +207 -0
- package/src/services/runtime-process-wiring.ts +68 -0
- package/src/services/runtime-rail-input.ts +287 -0
- package/src/services/runtime-render-flush.ts +146 -0
- package/src/services/runtime-render-lifecycle.ts +104 -0
- package/src/services/runtime-render-orchestrator.ts +108 -0
- package/src/services/runtime-render-pipeline.ts +167 -0
- package/src/services/runtime-render-state.ts +72 -0
- package/src/services/runtime-repository-actions.ts +197 -0
- package/src/services/runtime-right-pane-render.ts +132 -0
- package/src/services/runtime-shutdown.ts +79 -0
- package/src/services/runtime-stream-subscriptions.ts +56 -0
- package/src/services/runtime-task-composer-persistence.ts +139 -0
- package/src/services/runtime-task-editor-actions.ts +83 -0
- package/src/services/runtime-task-pane-actions.ts +198 -0
- package/src/services/runtime-task-pane-shortcuts.ts +189 -0
- package/src/services/runtime-task-pane.ts +62 -0
- package/src/services/runtime-workspace-actions.ts +153 -0
- package/src/services/runtime-workspace-observed-events.ts +190 -0
- package/src/services/session-projection-instrumentation.ts +190 -0
- package/src/services/startup-background-probe.ts +91 -0
- package/src/services/startup-background-resume.ts +65 -0
- package/src/services/startup-orchestrator.ts +166 -0
- package/src/services/startup-output-tracker.ts +54 -0
- package/src/services/startup-paint-tracker.ts +115 -0
- package/src/services/startup-persisted-conversation-queue.ts +45 -0
- package/src/services/startup-settled-gate.ts +67 -0
- package/src/services/startup-shutdown.ts +53 -0
- package/src/services/startup-span-tracker.ts +77 -0
- package/src/services/startup-state-hydration.ts +94 -0
- package/src/services/startup-visibility.ts +35 -0
- package/src/services/status-timeline-recorder.ts +144 -0
- package/src/services/task-pane-selection-actions.ts +153 -0
- package/src/services/task-planning-hydration.ts +58 -0
- package/src/services/task-planning-observed-events.ts +89 -0
- package/src/services/workspace-observed-events.ts +113 -0
- package/src/store/control-plane-store-normalize.ts +760 -0
- package/src/store/control-plane-store-types.ts +224 -0
- package/src/store/control-plane-store.ts +2951 -0
- package/src/store/event-store.ts +253 -0
- package/src/store/sqlite.ts +81 -0
- package/src/terminal/compat-matrix.ts +345 -0
- package/src/terminal/differential-checkpoints.ts +132 -0
- package/src/terminal/parity-suite.ts +441 -0
- package/src/terminal/snapshot-oracle.ts +1840 -0
- package/src/ui/conversation-input-forwarder.ts +114 -0
- package/src/ui/conversation-selection-input.ts +103 -0
- package/src/ui/debug-footer-notice.ts +39 -0
- package/src/ui/global-shortcut-input.ts +126 -0
- package/src/ui/input-preflight.ts +68 -0
- package/src/ui/input-token-router.ts +312 -0
- package/src/ui/input.ts +238 -0
- package/src/ui/kit.ts +509 -0
- package/src/ui/left-nav-input.ts +80 -0
- package/src/ui/left-rail-pointer-input.ts +148 -0
- package/src/ui/main-pane-pointer-input.ts +150 -0
- package/src/ui/modals/manager.ts +192 -0
- package/src/ui/mux-theme.ts +529 -0
- package/src/ui/panes/conversation.ts +19 -0
- package/src/ui/panes/home-gridfire.ts +302 -0
- package/src/ui/panes/home.ts +109 -0
- package/src/ui/panes/left-rail.ts +12 -0
- package/src/ui/panes/project.ts +44 -0
- package/src/ui/pointer-routing-input.ts +158 -0
- package/src/ui/repository-fold-input.ts +91 -0
- package/src/ui/screen.ts +210 -0
- package/src/ui/surface.ts +224 -0
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { dirname, resolve } from 'node:path';
|
|
4
|
+
|
|
5
|
+
export const CURSOR_HOOK_NOTIFY_FILE_ENV = 'HARNESS_CURSOR_NOTIFY_FILE';
|
|
6
|
+
export const CURSOR_HOOK_SESSION_ID_ENV = 'HARNESS_CURSOR_SESSION_ID';
|
|
7
|
+
export const CURSOR_MANAGED_HOOK_ID_PREFIX = 'harness-cursor-hook-v1';
|
|
8
|
+
|
|
9
|
+
const DEFAULT_CURSOR_MANAGED_HOOK_EVENTS = [
|
|
10
|
+
'beforeSubmitPrompt',
|
|
11
|
+
'beforeShellExecution',
|
|
12
|
+
'afterShellExecution',
|
|
13
|
+
'beforeMCPExecution',
|
|
14
|
+
'afterMCPExecution',
|
|
15
|
+
'stop',
|
|
16
|
+
] as const;
|
|
17
|
+
|
|
18
|
+
const LEGACY_CURSOR_HOOK_EVENT_MIGRATIONS = [
|
|
19
|
+
['beforeMCPTool', 'beforeMCPExecution'],
|
|
20
|
+
['afterMCPTool', 'afterMCPExecution'],
|
|
21
|
+
] as const;
|
|
22
|
+
|
|
23
|
+
interface CursorHookEntryRecord {
|
|
24
|
+
readonly [key: string]: unknown;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface CursorHooksRootRecord {
|
|
28
|
+
readonly [key: string]: unknown;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface ParsedCursorHooksFile {
|
|
32
|
+
readonly root: CursorHooksRootRecord;
|
|
33
|
+
readonly hooksByEvent: Readonly<Record<string, readonly CursorHookEntryRecord[]>>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface CursorManagedHooksResult {
|
|
37
|
+
readonly filePath: string;
|
|
38
|
+
readonly changed: boolean;
|
|
39
|
+
readonly removedCount: number;
|
|
40
|
+
readonly addedCount: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface EnsureManagedCursorHooksOptions {
|
|
44
|
+
readonly hooksFilePath?: string;
|
|
45
|
+
readonly relayCommand: string;
|
|
46
|
+
readonly managedEvents?: readonly string[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface UninstallManagedCursorHooksOptions {
|
|
50
|
+
readonly hooksFilePath?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
54
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
return value as Record<string, unknown>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function readNonEmptyString(value: unknown): string | null {
|
|
61
|
+
if (typeof value !== 'string') {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
const trimmed = value.trim();
|
|
65
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function shellEscape(value: string): string {
|
|
69
|
+
if (value.length === 0) {
|
|
70
|
+
return "''";
|
|
71
|
+
}
|
|
72
|
+
return `'${value.replaceAll("'", "'\"'\"'")}'`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function managedHookIdForEvent(eventName: string): string {
|
|
76
|
+
return `${CURSOR_MANAGED_HOOK_ID_PREFIX}:${eventName}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function managedHookCommandForEvent(relayCommand: string, eventName: string): string {
|
|
80
|
+
return `${relayCommand} --managed-hook-id ${shellEscape(managedHookIdForEvent(eventName))}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function isManagedHookCommand(command: string): boolean {
|
|
84
|
+
return command.includes(`--managed-hook-id '${CURSOR_MANAGED_HOOK_ID_PREFIX}:`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function cloneHooksByEvent(
|
|
88
|
+
hooksByEvent: Readonly<Record<string, readonly CursorHookEntryRecord[]>>,
|
|
89
|
+
): Record<string, CursorHookEntryRecord[]> {
|
|
90
|
+
const next: Record<string, CursorHookEntryRecord[]> = {};
|
|
91
|
+
for (const [eventName, entries] of Object.entries(hooksByEvent)) {
|
|
92
|
+
next[eventName] = entries.map((entry) => ({ ...entry }));
|
|
93
|
+
}
|
|
94
|
+
return next;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function parseCursorHooksFile(filePath: string): ParsedCursorHooksFile {
|
|
98
|
+
if (!existsSync(filePath)) {
|
|
99
|
+
return {
|
|
100
|
+
root: {
|
|
101
|
+
version: 1,
|
|
102
|
+
hooks: {},
|
|
103
|
+
},
|
|
104
|
+
hooksByEvent: {},
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
const parsed = JSON.parse(readFileSync(filePath, 'utf8')) as unknown;
|
|
108
|
+
const root = asRecord(parsed);
|
|
109
|
+
if (root === null) {
|
|
110
|
+
throw new Error(`cursor hooks file must contain a JSON object: ${filePath}`);
|
|
111
|
+
}
|
|
112
|
+
const rawHooks = root['hooks'];
|
|
113
|
+
if (rawHooks === undefined) {
|
|
114
|
+
return {
|
|
115
|
+
root,
|
|
116
|
+
hooksByEvent: {},
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
const hooksRecord = asRecord(rawHooks);
|
|
120
|
+
if (hooksRecord === null) {
|
|
121
|
+
throw new Error(`cursor hooks file has invalid hooks shape: ${filePath}`);
|
|
122
|
+
}
|
|
123
|
+
const hooksByEvent: Record<string, readonly CursorHookEntryRecord[]> = {};
|
|
124
|
+
for (const [eventName, rawEntries] of Object.entries(hooksRecord)) {
|
|
125
|
+
if (!Array.isArray(rawEntries)) {
|
|
126
|
+
throw new Error(`cursor hooks for event "${eventName}" must be an array`);
|
|
127
|
+
}
|
|
128
|
+
const entries: CursorHookEntryRecord[] = [];
|
|
129
|
+
for (const rawEntry of rawEntries) {
|
|
130
|
+
const entry = asRecord(rawEntry);
|
|
131
|
+
if (entry === null) {
|
|
132
|
+
throw new Error(`cursor hooks entry for event "${eventName}" must be an object`);
|
|
133
|
+
}
|
|
134
|
+
entries.push({ ...entry });
|
|
135
|
+
}
|
|
136
|
+
hooksByEvent[eventName] = entries;
|
|
137
|
+
}
|
|
138
|
+
return {
|
|
139
|
+
root,
|
|
140
|
+
hooksByEvent,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function serializeCursorHooksFile(
|
|
145
|
+
root: CursorHooksRootRecord,
|
|
146
|
+
hooksByEvent: Readonly<Record<string, readonly CursorHookEntryRecord[]>>,
|
|
147
|
+
): string {
|
|
148
|
+
const normalizedVersion = typeof root['version'] === 'number' ? root['version'] : 1;
|
|
149
|
+
return `${JSON.stringify(
|
|
150
|
+
{
|
|
151
|
+
...root,
|
|
152
|
+
version: normalizedVersion,
|
|
153
|
+
hooks: hooksByEvent,
|
|
154
|
+
},
|
|
155
|
+
null,
|
|
156
|
+
2,
|
|
157
|
+
)}\n`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function removeManagedHooks(hooksByEvent: Record<string, CursorHookEntryRecord[]>): number {
|
|
161
|
+
let removedCount = 0;
|
|
162
|
+
for (const [eventName, entries] of Object.entries(hooksByEvent)) {
|
|
163
|
+
const nextEntries = entries.filter((entry) => {
|
|
164
|
+
const command = readNonEmptyString(entry['command']);
|
|
165
|
+
if (command === null) {
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
if (!isManagedHookCommand(command)) {
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
removedCount += 1;
|
|
172
|
+
return false;
|
|
173
|
+
});
|
|
174
|
+
hooksByEvent[eventName] = nextEntries;
|
|
175
|
+
}
|
|
176
|
+
return removedCount;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function migrateLegacyHookEvents(hooksByEvent: Record<string, CursorHookEntryRecord[]>): void {
|
|
180
|
+
for (const [legacyEventName, nextEventName] of LEGACY_CURSOR_HOOK_EVENT_MIGRATIONS) {
|
|
181
|
+
const legacyEntries = hooksByEvent[legacyEventName];
|
|
182
|
+
if (legacyEntries === undefined) {
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
const nextEntries = hooksByEvent[nextEventName] ?? [];
|
|
186
|
+
nextEntries.push(...legacyEntries);
|
|
187
|
+
hooksByEvent[nextEventName] = nextEntries;
|
|
188
|
+
delete hooksByEvent[legacyEventName];
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function resolveManagedEvents(events: readonly string[] | undefined): readonly string[] {
|
|
193
|
+
if (events === undefined) {
|
|
194
|
+
return DEFAULT_CURSOR_MANAGED_HOOK_EVENTS;
|
|
195
|
+
}
|
|
196
|
+
const normalized = events
|
|
197
|
+
.map((eventName) => eventName.trim())
|
|
198
|
+
.filter((eventName) => eventName.length > 0);
|
|
199
|
+
if (normalized.length === 0) {
|
|
200
|
+
return DEFAULT_CURSOR_MANAGED_HOOK_EVENTS;
|
|
201
|
+
}
|
|
202
|
+
return [...new Set(normalized)];
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function resolveCursorHooksFilePath(filePath?: string): string {
|
|
206
|
+
const trimmed = filePath?.trim() ?? '';
|
|
207
|
+
if (trimmed.length > 0) return resolve(trimmed);
|
|
208
|
+
return resolve(homedir(), '.cursor', 'hooks.json');
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function buildCursorManagedHookRelayCommand(relayScriptPath: string): string {
|
|
212
|
+
return `/usr/bin/env ${shellEscape(process.execPath)} ${shellEscape(resolve(relayScriptPath))}`;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function buildCursorHookRelayEnvironment(
|
|
216
|
+
sessionId: string,
|
|
217
|
+
notifyFilePath: string,
|
|
218
|
+
): Record<string, string> {
|
|
219
|
+
return {
|
|
220
|
+
[CURSOR_HOOK_NOTIFY_FILE_ENV]: notifyFilePath,
|
|
221
|
+
[CURSOR_HOOK_SESSION_ID_ENV]: sessionId,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function ensureManagedCursorHooksInstalled(
|
|
226
|
+
options: EnsureManagedCursorHooksOptions,
|
|
227
|
+
): CursorManagedHooksResult {
|
|
228
|
+
const filePath = resolveCursorHooksFilePath(options.hooksFilePath);
|
|
229
|
+
const parsed = parseCursorHooksFile(filePath);
|
|
230
|
+
const nextHooksByEvent = cloneHooksByEvent(parsed.hooksByEvent);
|
|
231
|
+
const removedCount = removeManagedHooks(nextHooksByEvent);
|
|
232
|
+
migrateLegacyHookEvents(nextHooksByEvent);
|
|
233
|
+
const managedEvents = resolveManagedEvents(options.managedEvents);
|
|
234
|
+
let addedCount = 0;
|
|
235
|
+
for (const eventName of managedEvents) {
|
|
236
|
+
const nextEntries = nextHooksByEvent[eventName] ?? [];
|
|
237
|
+
const command = managedHookCommandForEvent(options.relayCommand, eventName);
|
|
238
|
+
if (!nextEntries.some((entry) => readNonEmptyString(entry['command']) === command)) {
|
|
239
|
+
nextEntries.push({
|
|
240
|
+
command,
|
|
241
|
+
});
|
|
242
|
+
addedCount += 1;
|
|
243
|
+
}
|
|
244
|
+
nextHooksByEvent[eventName] = nextEntries;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const beforeText = serializeCursorHooksFile(parsed.root, parsed.hooksByEvent);
|
|
248
|
+
const afterText = serializeCursorHooksFile(parsed.root, nextHooksByEvent);
|
|
249
|
+
const changed = beforeText !== afterText;
|
|
250
|
+
if (changed) {
|
|
251
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
252
|
+
writeFileSync(filePath, afterText, 'utf8');
|
|
253
|
+
}
|
|
254
|
+
return {
|
|
255
|
+
filePath,
|
|
256
|
+
changed,
|
|
257
|
+
removedCount,
|
|
258
|
+
addedCount,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export function uninstallManagedCursorHooks(
|
|
263
|
+
options: UninstallManagedCursorHooksOptions = {},
|
|
264
|
+
): CursorManagedHooksResult {
|
|
265
|
+
const filePath = resolveCursorHooksFilePath(options.hooksFilePath);
|
|
266
|
+
const parsed = parseCursorHooksFile(filePath);
|
|
267
|
+
const nextHooksByEvent = cloneHooksByEvent(parsed.hooksByEvent);
|
|
268
|
+
const removedCount = removeManagedHooks(nextHooksByEvent);
|
|
269
|
+
const beforeText = serializeCursorHooksFile(parsed.root, parsed.hooksByEvent);
|
|
270
|
+
const afterText = serializeCursorHooksFile(parsed.root, nextHooksByEvent);
|
|
271
|
+
const changed = beforeText !== afterText;
|
|
272
|
+
if (changed) {
|
|
273
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
274
|
+
writeFileSync(filePath, afterText, 'utf8');
|
|
275
|
+
}
|
|
276
|
+
return {
|
|
277
|
+
filePath,
|
|
278
|
+
changed,
|
|
279
|
+
removedCount,
|
|
280
|
+
addedCount: 0,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
import {
|
|
2
|
+
applySummaryToConversation,
|
|
3
|
+
type ConversationState,
|
|
4
|
+
} from '../mux/live-mux/conversation-state.ts';
|
|
5
|
+
import type { PtyExit } from '../pty/pty_host.ts';
|
|
6
|
+
|
|
7
|
+
export interface ConversationSeed {
|
|
8
|
+
directoryId?: string | null;
|
|
9
|
+
title?: string;
|
|
10
|
+
agentType?: string;
|
|
11
|
+
adapterState?: Record<string, unknown>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface PersistedConversationRecord {
|
|
15
|
+
conversationId: string;
|
|
16
|
+
directoryId: string;
|
|
17
|
+
tenantId: string;
|
|
18
|
+
userId: string;
|
|
19
|
+
workspaceId: string;
|
|
20
|
+
title: string;
|
|
21
|
+
agentType: string;
|
|
22
|
+
adapterState: Record<string, unknown>;
|
|
23
|
+
runtimeStatus: ConversationState['status'];
|
|
24
|
+
runtimeStatusModel: ConversationState['statusModel'];
|
|
25
|
+
runtimeLive: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface CreateConversationInput {
|
|
29
|
+
sessionId: string;
|
|
30
|
+
directoryId: string | null;
|
|
31
|
+
title: string;
|
|
32
|
+
agentType: string;
|
|
33
|
+
adapterState: Record<string, unknown>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface EnsureConversationDependencies {
|
|
37
|
+
resolveDefaultDirectoryId: () => string | null;
|
|
38
|
+
normalizeAdapterState: (value: Record<string, unknown> | undefined) => Record<string, unknown>;
|
|
39
|
+
createConversation: (input: CreateConversationInput) => ConversationState;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface UpsertPersistedConversationInput {
|
|
43
|
+
record: PersistedConversationRecord;
|
|
44
|
+
ensureConversation: (sessionId: string, seed?: ConversationSeed) => ConversationState;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
type SessionSummaryRecord = NonNullable<Parameters<typeof applySummaryToConversation>[1]>;
|
|
48
|
+
|
|
49
|
+
interface UpsertSessionSummaryInput {
|
|
50
|
+
summary: SessionSummaryRecord;
|
|
51
|
+
ensureConversation: (sessionId: string, seed?: ConversationSeed) => ConversationState;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface MarkSessionExitedInput {
|
|
55
|
+
sessionId: string;
|
|
56
|
+
exit: PtyExit;
|
|
57
|
+
exitedAt: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface IngestOutputChunkInput {
|
|
61
|
+
sessionId: string;
|
|
62
|
+
cursor: number;
|
|
63
|
+
chunk: Buffer;
|
|
64
|
+
ensureConversation: (sessionId: string, seed?: ConversationSeed) => ConversationState;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface IngestOutputChunkResult {
|
|
68
|
+
conversation: ConversationState;
|
|
69
|
+
cursorRegressed: boolean;
|
|
70
|
+
previousCursor: number;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
interface IsControlledByLocalHumanInput {
|
|
74
|
+
conversation: ConversationState;
|
|
75
|
+
controllerId: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface AttachIfLiveInput {
|
|
79
|
+
sessionId: string;
|
|
80
|
+
attach: (sinceCursor: number) => Promise<void>;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
interface AttachIfLiveResult {
|
|
84
|
+
attached: boolean;
|
|
85
|
+
conversation: ConversationState | null;
|
|
86
|
+
sinceCursor: number | null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
interface DetachIfAttachedInput {
|
|
90
|
+
sessionId: string;
|
|
91
|
+
detach: () => Promise<void>;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
interface DetachIfAttachedResult {
|
|
95
|
+
detached: boolean;
|
|
96
|
+
conversation: ConversationState | null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export class ConversationManager {
|
|
100
|
+
private readonly conversationsBySessionId = new Map<string, ConversationState>();
|
|
101
|
+
readonly startInFlightBySessionId = new Map<string, Promise<ConversationState>>();
|
|
102
|
+
readonly removedConversationIds = new Set<string>();
|
|
103
|
+
private ensureDependencies: EnsureConversationDependencies | null = null;
|
|
104
|
+
|
|
105
|
+
activeConversationId: string | null = null;
|
|
106
|
+
|
|
107
|
+
constructor() {}
|
|
108
|
+
|
|
109
|
+
get(sessionId: string): ConversationState | undefined {
|
|
110
|
+
return this.conversationsBySessionId.get(sessionId);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
has(sessionId: string): boolean {
|
|
114
|
+
return this.conversationsBySessionId.has(sessionId);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
set(state: ConversationState): void {
|
|
118
|
+
this.conversationsBySessionId.set(state.sessionId, state);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
readonlyConversations(): ReadonlyMap<string, ConversationState> {
|
|
122
|
+
return this.conversationsBySessionId;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
values(): IterableIterator<ConversationState> {
|
|
126
|
+
return this.conversationsBySessionId.values();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
size(): number {
|
|
130
|
+
return this.conversationsBySessionId.size;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
getActiveConversation(): ConversationState | null {
|
|
134
|
+
if (this.activeConversationId === null) {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
return this.conversationsBySessionId.get(this.activeConversationId) ?? null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
clearRemoved(sessionId: string): void {
|
|
141
|
+
this.removedConversationIds.delete(sessionId);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
isRemoved(sessionId: string): boolean {
|
|
145
|
+
return this.removedConversationIds.has(sessionId);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
getStartInFlight(sessionId: string): Promise<ConversationState> | undefined {
|
|
149
|
+
return this.startInFlightBySessionId.get(sessionId);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
setStartInFlight(sessionId: string, task: Promise<ConversationState>): void {
|
|
153
|
+
this.startInFlightBySessionId.set(sessionId, task);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
clearStartInFlight(sessionId: string): void {
|
|
157
|
+
this.startInFlightBySessionId.delete(sessionId);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
remove(sessionId: string): void {
|
|
161
|
+
this.removedConversationIds.add(sessionId);
|
|
162
|
+
this.conversationsBySessionId.delete(sessionId);
|
|
163
|
+
this.startInFlightBySessionId.delete(sessionId);
|
|
164
|
+
if (this.activeConversationId === sessionId) {
|
|
165
|
+
this.activeConversationId = null;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
orderedIds(): readonly string[] {
|
|
170
|
+
return [...this.conversationsBySessionId.keys()];
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
directoryIdOf(sessionId: string): string | null {
|
|
174
|
+
return this.conversationsBySessionId.get(sessionId)?.directoryId ?? null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
isLive(sessionId: string): boolean {
|
|
178
|
+
return this.conversationsBySessionId.get(sessionId)?.live === true;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
setController(
|
|
182
|
+
sessionId: string,
|
|
183
|
+
controller: ConversationState['controller'],
|
|
184
|
+
): ConversationState | null {
|
|
185
|
+
const conversation = this.conversationsBySessionId.get(sessionId);
|
|
186
|
+
if (conversation === undefined) {
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
conversation.controller = controller;
|
|
190
|
+
return conversation;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
setLastEventAt(sessionId: string, lastEventAt: string): ConversationState | null {
|
|
194
|
+
const conversation = this.conversationsBySessionId.get(sessionId);
|
|
195
|
+
if (conversation === undefined) {
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
conversation.lastEventAt = lastEventAt;
|
|
199
|
+
return conversation;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
findConversationIdByDirectory(
|
|
203
|
+
directoryId: string,
|
|
204
|
+
orderedIds: readonly string[] = this.orderedIds(),
|
|
205
|
+
): string | null {
|
|
206
|
+
for (const sessionId of orderedIds) {
|
|
207
|
+
if (this.directoryIdOf(sessionId) === directoryId) {
|
|
208
|
+
return sessionId;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
setActiveConversationId(sessionId: string | null): void {
|
|
215
|
+
this.activeConversationId = sessionId;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
configureEnsureDependencies(dependencies: EnsureConversationDependencies): void {
|
|
219
|
+
this.ensureDependencies = dependencies;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
ensureActiveConversationId(): string | null {
|
|
223
|
+
if (this.activeConversationId === null) {
|
|
224
|
+
this.activeConversationId = this.orderedIds()[0] ?? null;
|
|
225
|
+
}
|
|
226
|
+
return this.activeConversationId;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
ensure(sessionId: string, seed?: ConversationSeed): ConversationState {
|
|
230
|
+
if (this.ensureDependencies === null) {
|
|
231
|
+
throw new Error('conversation ensure dependencies are not configured');
|
|
232
|
+
}
|
|
233
|
+
const existing = this.conversationsBySessionId.get(sessionId);
|
|
234
|
+
if (existing !== undefined) {
|
|
235
|
+
if (seed?.directoryId !== undefined) {
|
|
236
|
+
existing.directoryId = seed.directoryId;
|
|
237
|
+
}
|
|
238
|
+
if (seed?.title !== undefined) {
|
|
239
|
+
existing.title = seed.title;
|
|
240
|
+
}
|
|
241
|
+
if (seed?.agentType !== undefined) {
|
|
242
|
+
existing.agentType = seed.agentType;
|
|
243
|
+
}
|
|
244
|
+
if (seed?.adapterState !== undefined) {
|
|
245
|
+
existing.adapterState = this.ensureDependencies.normalizeAdapterState(seed.adapterState);
|
|
246
|
+
}
|
|
247
|
+
return existing;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
this.clearRemoved(sessionId);
|
|
251
|
+
const directoryId = seed?.directoryId ?? this.ensureDependencies.resolveDefaultDirectoryId();
|
|
252
|
+
const state = this.ensureDependencies.createConversation({
|
|
253
|
+
sessionId,
|
|
254
|
+
directoryId,
|
|
255
|
+
title: seed?.title ?? '',
|
|
256
|
+
agentType: seed?.agentType ?? 'codex',
|
|
257
|
+
adapterState: this.ensureDependencies.normalizeAdapterState(seed?.adapterState),
|
|
258
|
+
});
|
|
259
|
+
this.set(state);
|
|
260
|
+
return state;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
requireActiveConversation(): ConversationState {
|
|
264
|
+
if (this.activeConversationId === null) {
|
|
265
|
+
throw new Error('active thread is not set');
|
|
266
|
+
}
|
|
267
|
+
const state = this.conversationsBySessionId.get(this.activeConversationId);
|
|
268
|
+
if (state === undefined) {
|
|
269
|
+
throw new Error(`active thread missing: ${this.activeConversationId}`);
|
|
270
|
+
}
|
|
271
|
+
return state;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async runWithStartInFlight(
|
|
275
|
+
sessionId: string,
|
|
276
|
+
factory: () => Promise<ConversationState>,
|
|
277
|
+
): Promise<ConversationState> {
|
|
278
|
+
const inFlight = this.getStartInFlight(sessionId);
|
|
279
|
+
if (inFlight !== undefined) {
|
|
280
|
+
return await inFlight;
|
|
281
|
+
}
|
|
282
|
+
const task = factory();
|
|
283
|
+
this.setStartInFlight(sessionId, task);
|
|
284
|
+
try {
|
|
285
|
+
return await task;
|
|
286
|
+
} finally {
|
|
287
|
+
this.clearStartInFlight(sessionId);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
upsertFromPersistedRecord(input: UpsertPersistedConversationInput): ConversationState {
|
|
292
|
+
const { record } = input;
|
|
293
|
+
const conversation = input.ensureConversation(record.conversationId, {
|
|
294
|
+
directoryId: record.directoryId,
|
|
295
|
+
title: record.title,
|
|
296
|
+
agentType: record.agentType,
|
|
297
|
+
adapterState: record.adapterState,
|
|
298
|
+
});
|
|
299
|
+
conversation.scope.tenantId = record.tenantId;
|
|
300
|
+
conversation.scope.userId = record.userId;
|
|
301
|
+
conversation.scope.workspaceId = record.workspaceId;
|
|
302
|
+
const runtimeStatusModel = record.runtimeStatusModel;
|
|
303
|
+
conversation.status = record.runtimeStatus;
|
|
304
|
+
conversation.statusModel = runtimeStatusModel;
|
|
305
|
+
conversation.attentionReason = runtimeStatusModel?.attentionReason ?? null;
|
|
306
|
+
conversation.lastKnownWork = runtimeStatusModel?.lastKnownWork ?? null;
|
|
307
|
+
conversation.lastKnownWorkAt = runtimeStatusModel?.lastKnownWorkAt ?? null;
|
|
308
|
+
// Persisted runtime flags are advisory; session.list is authoritative for live sessions.
|
|
309
|
+
conversation.live = false;
|
|
310
|
+
return conversation;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
upsertFromSessionSummary(input: UpsertSessionSummaryInput): ConversationState {
|
|
314
|
+
const conversation = input.ensureConversation(input.summary.sessionId);
|
|
315
|
+
applySummaryToConversation(conversation, input.summary);
|
|
316
|
+
return conversation;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
markSessionExited(input: MarkSessionExitedInput): ConversationState | null {
|
|
320
|
+
const conversation = this.conversationsBySessionId.get(input.sessionId);
|
|
321
|
+
if (conversation === undefined) {
|
|
322
|
+
return null;
|
|
323
|
+
}
|
|
324
|
+
conversation.status = 'exited';
|
|
325
|
+
conversation.live = false;
|
|
326
|
+
conversation.attentionReason = null;
|
|
327
|
+
conversation.lastExit = input.exit;
|
|
328
|
+
conversation.exitedAt = input.exitedAt;
|
|
329
|
+
conversation.attached = false;
|
|
330
|
+
return conversation;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
ingestOutputChunk(input: IngestOutputChunkInput): IngestOutputChunkResult {
|
|
334
|
+
const conversation = input.ensureConversation(input.sessionId);
|
|
335
|
+
const previousCursor = conversation.lastOutputCursor;
|
|
336
|
+
const cursorRegressed = input.cursor < previousCursor;
|
|
337
|
+
if (cursorRegressed) {
|
|
338
|
+
conversation.lastOutputCursor = 0;
|
|
339
|
+
}
|
|
340
|
+
conversation.oracle.ingest(input.chunk);
|
|
341
|
+
conversation.lastOutputCursor = input.cursor;
|
|
342
|
+
return {
|
|
343
|
+
conversation,
|
|
344
|
+
cursorRegressed,
|
|
345
|
+
previousCursor,
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
setAttached(sessionId: string, attached: boolean): ConversationState | null {
|
|
350
|
+
const conversation = this.conversationsBySessionId.get(sessionId);
|
|
351
|
+
if (conversation === undefined) {
|
|
352
|
+
return null;
|
|
353
|
+
}
|
|
354
|
+
conversation.attached = attached;
|
|
355
|
+
return conversation;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
markSessionUnavailable(sessionId: string): ConversationState | null {
|
|
359
|
+
const conversation = this.conversationsBySessionId.get(sessionId);
|
|
360
|
+
if (conversation === undefined) {
|
|
361
|
+
return null;
|
|
362
|
+
}
|
|
363
|
+
conversation.live = false;
|
|
364
|
+
conversation.attached = false;
|
|
365
|
+
if (conversation.status === 'running' || conversation.status === 'needs-input') {
|
|
366
|
+
conversation.status = 'completed';
|
|
367
|
+
conversation.attentionReason = null;
|
|
368
|
+
}
|
|
369
|
+
return conversation;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
isControlledByLocalHuman(input: IsControlledByLocalHumanInput): boolean {
|
|
373
|
+
return (
|
|
374
|
+
input.conversation.controller !== null &&
|
|
375
|
+
input.conversation.controller.controllerType === 'human' &&
|
|
376
|
+
input.conversation.controller.controllerId === input.controllerId
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
async attachIfLive(input: AttachIfLiveInput): Promise<AttachIfLiveResult> {
|
|
381
|
+
const conversation = this.conversationsBySessionId.get(input.sessionId);
|
|
382
|
+
if (conversation === undefined || !conversation.live || conversation.attached) {
|
|
383
|
+
return {
|
|
384
|
+
attached: false,
|
|
385
|
+
conversation: conversation ?? null,
|
|
386
|
+
sinceCursor: null,
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
const sinceCursor = Math.max(0, conversation.lastOutputCursor);
|
|
390
|
+
await input.attach(sinceCursor);
|
|
391
|
+
conversation.attached = true;
|
|
392
|
+
return {
|
|
393
|
+
attached: true,
|
|
394
|
+
conversation,
|
|
395
|
+
sinceCursor,
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
async detachIfAttached(input: DetachIfAttachedInput): Promise<DetachIfAttachedResult> {
|
|
400
|
+
const conversation = this.conversationsBySessionId.get(input.sessionId);
|
|
401
|
+
if (conversation === undefined || !conversation.attached) {
|
|
402
|
+
return {
|
|
403
|
+
detached: false,
|
|
404
|
+
conversation: conversation ?? null,
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
await input.detach();
|
|
408
|
+
conversation.attached = false;
|
|
409
|
+
return {
|
|
410
|
+
detached: true,
|
|
411
|
+
conversation,
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
}
|