@kolisachint/hoocode-agent 0.4.52 → 0.4.54
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 +35 -0
- package/dist/cli/args.d.ts +1 -1
- package/dist/cli/args.d.ts.map +1 -1
- package/dist/cli/args.js +4 -1
- package/dist/cli/args.js.map +1 -1
- package/dist/core/keybindings.d.ts +5 -0
- package/dist/core/keybindings.d.ts.map +1 -1
- package/dist/core/keybindings.js +8 -0
- package/dist/core/keybindings.js.map +1 -1
- package/dist/core/team-approvals.d.ts +55 -0
- package/dist/core/team-approvals.d.ts.map +1 -0
- package/dist/core/team-approvals.js +95 -0
- package/dist/core/team-approvals.js.map +1 -0
- package/dist/core/team-auto.d.ts +46 -0
- package/dist/core/team-auto.d.ts.map +1 -0
- package/dist/core/team-auto.js +163 -0
- package/dist/core/team-auto.js.map +1 -0
- package/dist/core/team-view.d.ts +58 -2
- package/dist/core/team-view.d.ts.map +1 -1
- package/dist/core/team-view.js +89 -4
- package/dist/core/team-view.js.map +1 -1
- package/dist/main.d.ts.map +1 -1
- package/dist/main.js +28 -7
- package/dist/main.js.map +1 -1
- package/dist/modes/interactive/components/task-panel.d.ts +14 -2
- package/dist/modes/interactive/components/task-panel.d.ts.map +1 -1
- package/dist/modes/interactive/components/task-panel.js +69 -5
- package/dist/modes/interactive/components/task-panel.js.map +1 -1
- package/dist/modes/interactive/components/team-attach-panel.d.ts +60 -0
- package/dist/modes/interactive/components/team-attach-panel.d.ts.map +1 -0
- package/dist/modes/interactive/components/team-attach-panel.js +222 -0
- package/dist/modes/interactive/components/team-attach-panel.js.map +1 -0
- package/dist/modes/interactive/interactive-mode.d.ts +28 -0
- package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
- package/dist/modes/interactive/interactive-mode.js +147 -0
- package/dist/modes/interactive/interactive-mode.js.map +1 -1
- package/examples/extensions/custom-provider-anthropic/package.json +1 -1
- package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
- package/examples/extensions/sandbox/package.json +1 -1
- package/examples/extensions/with-deps/package.json +1 -1
- package/package.json +4 -4
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Approval-gate coordinator for the hooteams bridge (`--team`).
|
|
3
|
+
*
|
|
4
|
+
* The orchestrator pauses a task by emitting a `task_paused` TeamEvent
|
|
5
|
+
* (question + options) and waits for `POST /tasks/:id/resume`. This class
|
|
6
|
+
* turns that wire contract into the TUI's options pane: it queues gates as
|
|
7
|
+
* they arrive (one prompt on screen at a time), answers the server with the
|
|
8
|
+
* chosen option, and dismisses prompts that another surface (hoocanvas,
|
|
9
|
+
* another attached hoocode) answered first — the server enforces
|
|
10
|
+
* first-answer-wins and 409s stale answers.
|
|
11
|
+
*
|
|
12
|
+
* The coordinator is UI-agnostic: the host injects `present` (show a gate,
|
|
13
|
+
* resolve with the answer, undefined on skip, abort signal on dismissal),
|
|
14
|
+
* which interactive mode backs with its AskOptions pane.
|
|
15
|
+
*/
|
|
16
|
+
import type { TeamPendingApproval, TeamViewEvent } from "./team-view.js";
|
|
17
|
+
/** One approval gate, as queued for presentation. */
|
|
18
|
+
export interface TeamApproval {
|
|
19
|
+
taskId: string;
|
|
20
|
+
question: string;
|
|
21
|
+
options: string[];
|
|
22
|
+
/** Role that paused; absent for gates fetched from /tasks/pending. */
|
|
23
|
+
role?: string;
|
|
24
|
+
}
|
|
25
|
+
export interface TeamApprovalHost {
|
|
26
|
+
/**
|
|
27
|
+
* Show the gate and resolve with the chosen option (free-form answers
|
|
28
|
+
* allowed), or undefined when the user skips it. The signal aborts when
|
|
29
|
+
* the gate is dismissed (answered elsewhere); resolve promptly then.
|
|
30
|
+
*/
|
|
31
|
+
present(approval: TeamApproval, signal: AbortSignal): Promise<string | undefined>;
|
|
32
|
+
/** Deliver the answer: POST /tasks/:id/resume. Rejects on stale/HTTP errors. */
|
|
33
|
+
resume(taskId: string, option: string): Promise<void>;
|
|
34
|
+
info(message: string): void;
|
|
35
|
+
warn(message: string): void;
|
|
36
|
+
}
|
|
37
|
+
export declare class TeamApprovalCoordinator {
|
|
38
|
+
private readonly host;
|
|
39
|
+
private readonly queue;
|
|
40
|
+
private current;
|
|
41
|
+
constructor(host: TeamApprovalHost);
|
|
42
|
+
/** Feed every TeamEvent from the shared /events subscription. */
|
|
43
|
+
handleEvent(event: TeamViewEvent): void;
|
|
44
|
+
/** Queue gates that opened before we attached (GET /tasks/pending). */
|
|
45
|
+
enqueuePending(pending: TeamPendingApproval): void;
|
|
46
|
+
/** Number of gates waiting behind the one on screen. Exposed for tests. */
|
|
47
|
+
queuedCount(): number;
|
|
48
|
+
/** Task id of the gate currently on screen, if any. Exposed for tests. */
|
|
49
|
+
presentedTaskId(): string | undefined;
|
|
50
|
+
private enqueue;
|
|
51
|
+
/** Drop a gate another surface settled: silently when queued, with a notice when on screen. */
|
|
52
|
+
private dismiss;
|
|
53
|
+
private pump;
|
|
54
|
+
}
|
|
55
|
+
//# sourceMappingURL=team-approvals.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"team-approvals.d.ts","sourceRoot":"","sources":["../../src/core/team-approvals.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,KAAK,EAAE,mBAAmB,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAEzE,qDAAqD;AACrD,MAAM,WAAW,YAAY;IAC5B,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,sEAAsE;IACtE,IAAI,CAAC,EAAE,MAAM,CAAC;CACd;AAED,MAAM,WAAW,gBAAgB;IAChC;;;;OAIG;IACH,OAAO,CAAC,QAAQ,EAAE,YAAY,EAAE,MAAM,EAAE,WAAW,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAAC;IAClF,gFAAgF;IAChF,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACtD,IAAI,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,IAAI,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;CAC5B;AAED,qBAAa,uBAAuB;IAIvB,OAAO,CAAC,QAAQ,CAAC,IAAI;IAHjC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAsB;IAC5C,OAAO,CAAC,OAAO,CAAiE;IAEhF,YAA6B,IAAI,EAAE,gBAAgB,EAAI;IAEvD,iEAAiE;IACjE,WAAW,CAAC,KAAK,EAAE,aAAa,GAAG,IAAI,CActC;IAED,uEAAuE;IACvE,cAAc,CAAC,OAAO,EAAE,mBAAmB,GAAG,IAAI,CAEjD;IAED,2EAA2E;IAC3E,WAAW,IAAI,MAAM,CAEpB;IAED,0EAA0E;IAC1E,eAAe,IAAI,MAAM,GAAG,SAAS,CAEpC;IAED,OAAO,CAAC,OAAO;IAOf,+FAA+F;IAC/F,OAAO,CAAC,OAAO;IAQf,OAAO,CAAC,IAAI;CA6BZ","sourcesContent":["/**\n * Approval-gate coordinator for the hooteams bridge (`--team`).\n *\n * The orchestrator pauses a task by emitting a `task_paused` TeamEvent\n * (question + options) and waits for `POST /tasks/:id/resume`. This class\n * turns that wire contract into the TUI's options pane: it queues gates as\n * they arrive (one prompt on screen at a time), answers the server with the\n * chosen option, and dismisses prompts that another surface (hoocanvas,\n * another attached hoocode) answered first — the server enforces\n * first-answer-wins and 409s stale answers.\n *\n * The coordinator is UI-agnostic: the host injects `present` (show a gate,\n * resolve with the answer, undefined on skip, abort signal on dismissal),\n * which interactive mode backs with its AskOptions pane.\n */\n\nimport type { TeamPendingApproval, TeamViewEvent } from \"./team-view.js\";\n\n/** One approval gate, as queued for presentation. */\nexport interface TeamApproval {\n\ttaskId: string;\n\tquestion: string;\n\toptions: string[];\n\t/** Role that paused; absent for gates fetched from /tasks/pending. */\n\trole?: string;\n}\n\nexport interface TeamApprovalHost {\n\t/**\n\t * Show the gate and resolve with the chosen option (free-form answers\n\t * allowed), or undefined when the user skips it. The signal aborts when\n\t * the gate is dismissed (answered elsewhere); resolve promptly then.\n\t */\n\tpresent(approval: TeamApproval, signal: AbortSignal): Promise<string | undefined>;\n\t/** Deliver the answer: POST /tasks/:id/resume. Rejects on stale/HTTP errors. */\n\tresume(taskId: string, option: string): Promise<void>;\n\tinfo(message: string): void;\n\twarn(message: string): void;\n}\n\nexport class TeamApprovalCoordinator {\n\tprivate readonly queue: TeamApproval[] = [];\n\tprivate current: { approval: TeamApproval; abort: AbortController } | undefined;\n\n\tconstructor(private readonly host: TeamApprovalHost) {}\n\n\t/** Feed every TeamEvent from the shared /events subscription. */\n\thandleEvent(event: TeamViewEvent): void {\n\t\tif (event.type === \"task_paused\" && typeof event.taskId === \"string\" && typeof event.question === \"string\") {\n\t\t\tthis.enqueue({\n\t\t\t\ttaskId: event.taskId,\n\t\t\t\tquestion: event.question,\n\t\t\t\toptions: Array.isArray(event.options) ? event.options : [],\n\t\t\t\trole: event.role,\n\t\t\t});\n\t\t} else if (\n\t\t\t(event.type === \"task_resumed\" || event.type === \"task_finished\") &&\n\t\t\ttypeof event.taskId === \"string\"\n\t\t) {\n\t\t\tthis.dismiss(event.taskId);\n\t\t}\n\t}\n\n\t/** Queue gates that opened before we attached (GET /tasks/pending). */\n\tenqueuePending(pending: TeamPendingApproval): void {\n\t\tthis.enqueue({ taskId: pending.taskId, question: pending.question, options: pending.options });\n\t}\n\n\t/** Number of gates waiting behind the one on screen. Exposed for tests. */\n\tqueuedCount(): number {\n\t\treturn this.queue.length;\n\t}\n\n\t/** Task id of the gate currently on screen, if any. Exposed for tests. */\n\tpresentedTaskId(): string | undefined {\n\t\treturn this.current?.approval.taskId;\n\t}\n\n\tprivate enqueue(approval: TeamApproval): void {\n\t\tif (this.current?.approval.taskId === approval.taskId) return;\n\t\tif (this.queue.some((queued) => queued.taskId === approval.taskId)) return;\n\t\tthis.queue.push(approval);\n\t\tthis.pump();\n\t}\n\n\t/** Drop a gate another surface settled: silently when queued, with a notice when on screen. */\n\tprivate dismiss(taskId: string): void {\n\t\tconst index = this.queue.findIndex((queued) => queued.taskId === taskId);\n\t\tif (index !== -1) this.queue.splice(index, 1);\n\t\tif (this.current?.approval.taskId === taskId) {\n\t\t\tthis.current.abort.abort();\n\t\t}\n\t}\n\n\tprivate pump(): void {\n\t\tif (this.current) return;\n\t\tconst next = this.queue.shift();\n\t\tif (!next) return;\n\t\tconst abort = new AbortController();\n\t\tthis.current = { approval: next, abort };\n\t\tvoid this.host.present(next, abort.signal).then(\n\t\t\t(answer) => {\n\t\t\t\tconst dismissed = abort.signal.aborted;\n\t\t\t\tthis.current = undefined;\n\t\t\t\tif (dismissed) {\n\t\t\t\t\tthis.host.info(`Approval for \"${next.taskId}\" was answered from another surface.`);\n\t\t\t\t} else if (answer !== undefined) {\n\t\t\t\t\tvoid this.host.resume(next.taskId, answer).then(\n\t\t\t\t\t\t() => this.host.info(`Resumed \"${next.taskId}\" with \"${answer}\".`),\n\t\t\t\t\t\t(error) => this.host.warn(`Failed to resume \"${next.taskId}\": ${String(error)}`),\n\t\t\t\t\t);\n\t\t\t\t} else {\n\t\t\t\t\tthis.host.info(`Left \"${next.taskId}\" paused — it stays pending on the team server.`);\n\t\t\t\t}\n\t\t\t\tthis.pump();\n\t\t\t},\n\t\t\t(error) => {\n\t\t\t\tthis.current = undefined;\n\t\t\t\tthis.host.warn(`Approval prompt for \"${next.taskId}\" failed: ${String(error)}`);\n\t\t\t\tthis.pump();\n\t\t\t},\n\t\t);\n\t}\n}\n"]}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Approval-gate coordinator for the hooteams bridge (`--team`).
|
|
3
|
+
*
|
|
4
|
+
* The orchestrator pauses a task by emitting a `task_paused` TeamEvent
|
|
5
|
+
* (question + options) and waits for `POST /tasks/:id/resume`. This class
|
|
6
|
+
* turns that wire contract into the TUI's options pane: it queues gates as
|
|
7
|
+
* they arrive (one prompt on screen at a time), answers the server with the
|
|
8
|
+
* chosen option, and dismisses prompts that another surface (hoocanvas,
|
|
9
|
+
* another attached hoocode) answered first — the server enforces
|
|
10
|
+
* first-answer-wins and 409s stale answers.
|
|
11
|
+
*
|
|
12
|
+
* The coordinator is UI-agnostic: the host injects `present` (show a gate,
|
|
13
|
+
* resolve with the answer, undefined on skip, abort signal on dismissal),
|
|
14
|
+
* which interactive mode backs with its AskOptions pane.
|
|
15
|
+
*/
|
|
16
|
+
export class TeamApprovalCoordinator {
|
|
17
|
+
host;
|
|
18
|
+
queue = [];
|
|
19
|
+
current;
|
|
20
|
+
constructor(host) {
|
|
21
|
+
this.host = host;
|
|
22
|
+
}
|
|
23
|
+
/** Feed every TeamEvent from the shared /events subscription. */
|
|
24
|
+
handleEvent(event) {
|
|
25
|
+
if (event.type === "task_paused" && typeof event.taskId === "string" && typeof event.question === "string") {
|
|
26
|
+
this.enqueue({
|
|
27
|
+
taskId: event.taskId,
|
|
28
|
+
question: event.question,
|
|
29
|
+
options: Array.isArray(event.options) ? event.options : [],
|
|
30
|
+
role: event.role,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
else if ((event.type === "task_resumed" || event.type === "task_finished") &&
|
|
34
|
+
typeof event.taskId === "string") {
|
|
35
|
+
this.dismiss(event.taskId);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/** Queue gates that opened before we attached (GET /tasks/pending). */
|
|
39
|
+
enqueuePending(pending) {
|
|
40
|
+
this.enqueue({ taskId: pending.taskId, question: pending.question, options: pending.options });
|
|
41
|
+
}
|
|
42
|
+
/** Number of gates waiting behind the one on screen. Exposed for tests. */
|
|
43
|
+
queuedCount() {
|
|
44
|
+
return this.queue.length;
|
|
45
|
+
}
|
|
46
|
+
/** Task id of the gate currently on screen, if any. Exposed for tests. */
|
|
47
|
+
presentedTaskId() {
|
|
48
|
+
return this.current?.approval.taskId;
|
|
49
|
+
}
|
|
50
|
+
enqueue(approval) {
|
|
51
|
+
if (this.current?.approval.taskId === approval.taskId)
|
|
52
|
+
return;
|
|
53
|
+
if (this.queue.some((queued) => queued.taskId === approval.taskId))
|
|
54
|
+
return;
|
|
55
|
+
this.queue.push(approval);
|
|
56
|
+
this.pump();
|
|
57
|
+
}
|
|
58
|
+
/** Drop a gate another surface settled: silently when queued, with a notice when on screen. */
|
|
59
|
+
dismiss(taskId) {
|
|
60
|
+
const index = this.queue.findIndex((queued) => queued.taskId === taskId);
|
|
61
|
+
if (index !== -1)
|
|
62
|
+
this.queue.splice(index, 1);
|
|
63
|
+
if (this.current?.approval.taskId === taskId) {
|
|
64
|
+
this.current.abort.abort();
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
pump() {
|
|
68
|
+
if (this.current)
|
|
69
|
+
return;
|
|
70
|
+
const next = this.queue.shift();
|
|
71
|
+
if (!next)
|
|
72
|
+
return;
|
|
73
|
+
const abort = new AbortController();
|
|
74
|
+
this.current = { approval: next, abort };
|
|
75
|
+
void this.host.present(next, abort.signal).then((answer) => {
|
|
76
|
+
const dismissed = abort.signal.aborted;
|
|
77
|
+
this.current = undefined;
|
|
78
|
+
if (dismissed) {
|
|
79
|
+
this.host.info(`Approval for "${next.taskId}" was answered from another surface.`);
|
|
80
|
+
}
|
|
81
|
+
else if (answer !== undefined) {
|
|
82
|
+
void this.host.resume(next.taskId, answer).then(() => this.host.info(`Resumed "${next.taskId}" with "${answer}".`), (error) => this.host.warn(`Failed to resume "${next.taskId}": ${String(error)}`));
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
this.host.info(`Left "${next.taskId}" paused — it stays pending on the team server.`);
|
|
86
|
+
}
|
|
87
|
+
this.pump();
|
|
88
|
+
}, (error) => {
|
|
89
|
+
this.current = undefined;
|
|
90
|
+
this.host.warn(`Approval prompt for "${next.taskId}" failed: ${String(error)}`);
|
|
91
|
+
this.pump();
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
//# sourceMappingURL=team-approvals.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"team-approvals.js","sourceRoot":"","sources":["../../src/core/team-approvals.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Approval-gate coordinator for the hooteams bridge (`--team`).\n *\n * The orchestrator pauses a task by emitting a `task_paused` TeamEvent\n * (question + options) and waits for `POST /tasks/:id/resume`. This class\n * turns that wire contract into the TUI's options pane: it queues gates as\n * they arrive (one prompt on screen at a time), answers the server with the\n * chosen option, and dismisses prompts that another surface (hoocanvas,\n * another attached hoocode) answered first — the server enforces\n * first-answer-wins and 409s stale answers.\n *\n * The coordinator is UI-agnostic: the host injects `present` (show a gate,\n * resolve with the answer, undefined on skip, abort signal on dismissal),\n * which interactive mode backs with its AskOptions pane.\n */\n\nimport type { TeamPendingApproval, TeamViewEvent } from \"./team-view.js\";\n\n/** One approval gate, as queued for presentation. */\nexport interface TeamApproval {\n\ttaskId: string;\n\tquestion: string;\n\toptions: string[];\n\t/** Role that paused; absent for gates fetched from /tasks/pending. */\n\trole?: string;\n}\n\nexport interface TeamApprovalHost {\n\t/**\n\t * Show the gate and resolve with the chosen option (free-form answers\n\t * allowed), or undefined when the user skips it. The signal aborts when\n\t * the gate is dismissed (answered elsewhere); resolve promptly then.\n\t */\n\tpresent(approval: TeamApproval, signal: AbortSignal): Promise<string | undefined>;\n\t/** Deliver the answer: POST /tasks/:id/resume. Rejects on stale/HTTP errors. */\n\tresume(taskId: string, option: string): Promise<void>;\n\tinfo(message: string): void;\n\twarn(message: string): void;\n}\n\nexport class TeamApprovalCoordinator {\n\tprivate readonly queue: TeamApproval[] = [];\n\tprivate current: { approval: TeamApproval; abort: AbortController } | undefined;\n\n\tconstructor(private readonly host: TeamApprovalHost) {}\n\n\t/** Feed every TeamEvent from the shared /events subscription. */\n\thandleEvent(event: TeamViewEvent): void {\n\t\tif (event.type === \"task_paused\" && typeof event.taskId === \"string\" && typeof event.question === \"string\") {\n\t\t\tthis.enqueue({\n\t\t\t\ttaskId: event.taskId,\n\t\t\t\tquestion: event.question,\n\t\t\t\toptions: Array.isArray(event.options) ? event.options : [],\n\t\t\t\trole: event.role,\n\t\t\t});\n\t\t} else if (\n\t\t\t(event.type === \"task_resumed\" || event.type === \"task_finished\") &&\n\t\t\ttypeof event.taskId === \"string\"\n\t\t) {\n\t\t\tthis.dismiss(event.taskId);\n\t\t}\n\t}\n\n\t/** Queue gates that opened before we attached (GET /tasks/pending). */\n\tenqueuePending(pending: TeamPendingApproval): void {\n\t\tthis.enqueue({ taskId: pending.taskId, question: pending.question, options: pending.options });\n\t}\n\n\t/** Number of gates waiting behind the one on screen. Exposed for tests. */\n\tqueuedCount(): number {\n\t\treturn this.queue.length;\n\t}\n\n\t/** Task id of the gate currently on screen, if any. Exposed for tests. */\n\tpresentedTaskId(): string | undefined {\n\t\treturn this.current?.approval.taskId;\n\t}\n\n\tprivate enqueue(approval: TeamApproval): void {\n\t\tif (this.current?.approval.taskId === approval.taskId) return;\n\t\tif (this.queue.some((queued) => queued.taskId === approval.taskId)) return;\n\t\tthis.queue.push(approval);\n\t\tthis.pump();\n\t}\n\n\t/** Drop a gate another surface settled: silently when queued, with a notice when on screen. */\n\tprivate dismiss(taskId: string): void {\n\t\tconst index = this.queue.findIndex((queued) => queued.taskId === taskId);\n\t\tif (index !== -1) this.queue.splice(index, 1);\n\t\tif (this.current?.approval.taskId === taskId) {\n\t\t\tthis.current.abort.abort();\n\t\t}\n\t}\n\n\tprivate pump(): void {\n\t\tif (this.current) return;\n\t\tconst next = this.queue.shift();\n\t\tif (!next) return;\n\t\tconst abort = new AbortController();\n\t\tthis.current = { approval: next, abort };\n\t\tvoid this.host.present(next, abort.signal).then(\n\t\t\t(answer) => {\n\t\t\t\tconst dismissed = abort.signal.aborted;\n\t\t\t\tthis.current = undefined;\n\t\t\t\tif (dismissed) {\n\t\t\t\t\tthis.host.info(`Approval for \"${next.taskId}\" was answered from another surface.`);\n\t\t\t\t} else if (answer !== undefined) {\n\t\t\t\t\tvoid this.host.resume(next.taskId, answer).then(\n\t\t\t\t\t\t() => this.host.info(`Resumed \"${next.taskId}\" with \"${answer}\".`),\n\t\t\t\t\t\t(error) => this.host.warn(`Failed to resume \"${next.taskId}\": ${String(error)}`),\n\t\t\t\t\t);\n\t\t\t\t} else {\n\t\t\t\t\tthis.host.info(`Left \"${next.taskId}\" paused — it stays pending on the team server.`);\n\t\t\t\t}\n\t\t\t\tthis.pump();\n\t\t\t},\n\t\t\t(error) => {\n\t\t\t\tthis.current = undefined;\n\t\t\t\tthis.host.warn(`Approval prompt for \"${next.taskId}\" failed: ${String(error)}`);\n\t\t\t\tthis.pump();\n\t\t\t},\n\t\t);\n\t}\n}\n"]}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `--team auto`: discover a team config, spawn a local hooteams server as a
|
|
3
|
+
* child process, and hand back its URL so the rest of the pipeline behaves
|
|
4
|
+
* exactly as if `--team http://localhost:<port>` had been passed.
|
|
5
|
+
*
|
|
6
|
+
* hooteams is intentionally not bundled — the launcher is resolved from PATH
|
|
7
|
+
* (`hooteams`, falling back to `bunx hooteams`) and missing pieces fail with
|
|
8
|
+
* a clear, actionable error. The child is reaped on hoocode exit, clean or
|
|
9
|
+
* signalled, via a process "exit" hook (the interactive shutdown path calls
|
|
10
|
+
* process.exit directly, so an async cleanup would never run).
|
|
11
|
+
*/
|
|
12
|
+
/** Config locations probed at each directory level, in priority order. */
|
|
13
|
+
export declare const TEAM_CONFIG_CANDIDATES: string[];
|
|
14
|
+
/**
|
|
15
|
+
* Walk up from startDir to the filesystem root, returning the first config
|
|
16
|
+
* found. Both candidates are probed per level (.agents/teams/default.json
|
|
17
|
+
* wins over hooteams.config.json in the same directory).
|
|
18
|
+
*/
|
|
19
|
+
export declare function findTeamConfig(startDir: string): string | undefined;
|
|
20
|
+
/** Ask the OS for a free port by binding port 0 and reading the assignment. */
|
|
21
|
+
export declare function findFreePort(): Promise<number>;
|
|
22
|
+
/** How to launch hooteams: directly from PATH, or through bunx. */
|
|
23
|
+
export declare function resolveHooteamsLauncher(env?: NodeJS.ProcessEnv): {
|
|
24
|
+
command: string;
|
|
25
|
+
prefixArgs: string[];
|
|
26
|
+
} | undefined;
|
|
27
|
+
export interface AutoTeam {
|
|
28
|
+
/** Base URL of the spawned hooteams server. */
|
|
29
|
+
url: string;
|
|
30
|
+
/** Graceful shutdown: POST /stop, then kill the child's process group. */
|
|
31
|
+
stop(): Promise<void>;
|
|
32
|
+
}
|
|
33
|
+
export interface AutoTeamOptions {
|
|
34
|
+
/** Startup progress sink (pre-TUI, so console is fine). */
|
|
35
|
+
log?: (message: string) => void;
|
|
36
|
+
/** How long to wait for GET /health (default 15s). */
|
|
37
|
+
healthTimeoutMs?: number;
|
|
38
|
+
env?: NodeJS.ProcessEnv;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Resolve the config, spawn hooteams on a free port, and wait for /health.
|
|
42
|
+
* Throws (with a message ready for the terminal) when no config is found, no
|
|
43
|
+
* launcher resolves, or the server never becomes healthy.
|
|
44
|
+
*/
|
|
45
|
+
export declare function startAutoTeam(cwd: string, options?: AutoTeamOptions): Promise<AutoTeam>;
|
|
46
|
+
//# sourceMappingURL=team-auto.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"team-auto.d.ts","sourceRoot":"","sources":["../../src/core/team-auto.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAOH,0EAA0E;AAC1E,eAAO,MAAM,sBAAsB,UAA0E,CAAC;AAE9G;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAWnE;AAED,+EAA+E;AAC/E,wBAAgB,YAAY,IAAI,OAAO,CAAC,MAAM,CAAC,CAc9C;AAmBD,mEAAmE;AACnE,wBAAgB,uBAAuB,CACtC,GAAG,GAAE,MAAM,CAAC,UAAwB,GAClC;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,MAAM,EAAE,CAAA;CAAE,GAAG,SAAS,CAIvD;AAED,MAAM,WAAW,QAAQ;IACxB,+CAA+C;IAC/C,GAAG,EAAE,MAAM,CAAC;IACZ,0EAA0E;IAC1E,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACtB;AAED,MAAM,WAAW,eAAe;IAC/B,2DAA2D;IAC3D,GAAG,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;IAChC,sDAAsD;IACtD,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;CACxB;AAkCD;;;;GAIG;AACH,wBAAsB,aAAa,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,GAAE,eAAoB,GAAG,OAAO,CAAC,QAAQ,CAAC,CAoDjG","sourcesContent":["/**\n * `--team auto`: discover a team config, spawn a local hooteams server as a\n * child process, and hand back its URL so the rest of the pipeline behaves\n * exactly as if `--team http://localhost:<port>` had been passed.\n *\n * hooteams is intentionally not bundled — the launcher is resolved from PATH\n * (`hooteams`, falling back to `bunx hooteams`) and missing pieces fail with\n * a clear, actionable error. The child is reaped on hoocode exit, clean or\n * signalled, via a process \"exit\" hook (the interactive shutdown path calls\n * process.exit directly, so an async cleanup would never run).\n */\n\nimport { type ChildProcess, spawn } from \"node:child_process\";\nimport { accessSync, constants, existsSync } from \"node:fs\";\nimport { createServer } from \"node:net\";\nimport path from \"node:path\";\n\n/** Config locations probed at each directory level, in priority order. */\nexport const TEAM_CONFIG_CANDIDATES = [path.join(\".agents\", \"teams\", \"default.json\"), \"hooteams.config.json\"];\n\n/**\n * Walk up from startDir to the filesystem root, returning the first config\n * found. Both candidates are probed per level (.agents/teams/default.json\n * wins over hooteams.config.json in the same directory).\n */\nexport function findTeamConfig(startDir: string): string | undefined {\n\tlet dir = path.resolve(startDir);\n\twhile (true) {\n\t\tfor (const candidate of TEAM_CONFIG_CANDIDATES) {\n\t\t\tconst candidatePath = path.join(dir, candidate);\n\t\t\tif (existsSync(candidatePath)) return candidatePath;\n\t\t}\n\t\tconst parent = path.dirname(dir);\n\t\tif (parent === dir) return undefined;\n\t\tdir = parent;\n\t}\n}\n\n/** Ask the OS for a free port by binding port 0 and reading the assignment. */\nexport function findFreePort(): Promise<number> {\n\treturn new Promise((resolve, reject) => {\n\t\tconst server = createServer();\n\t\tserver.once(\"error\", reject);\n\t\tserver.listen(0, \"127.0.0.1\", () => {\n\t\t\tconst address = server.address();\n\t\t\tif (address === null || typeof address === \"string\") {\n\t\t\t\tserver.close(() => reject(new Error(\"could not determine a free port\")));\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tconst { port } = address;\n\t\t\tserver.close(() => resolve(port));\n\t\t});\n\t});\n}\n\nfunction isExecutableOnPath(name: string, env: NodeJS.ProcessEnv): boolean {\n\tconst pathVar = env.PATH ?? \"\";\n\tconst extensions = process.platform === \"win32\" ? (env.PATHEXT ?? \".EXE;.CMD;.BAT;.COM\").split(\";\") : [\"\"];\n\tfor (const dir of pathVar.split(path.delimiter)) {\n\t\tif (!dir) continue;\n\t\tfor (const extension of extensions) {\n\t\t\ttry {\n\t\t\t\taccessSync(path.join(dir, name + extension.toLowerCase()), constants.X_OK);\n\t\t\t\treturn true;\n\t\t\t} catch {\n\t\t\t\t// keep probing\n\t\t\t}\n\t\t}\n\t}\n\treturn false;\n}\n\n/** How to launch hooteams: directly from PATH, or through bunx. */\nexport function resolveHooteamsLauncher(\n\tenv: NodeJS.ProcessEnv = process.env,\n): { command: string; prefixArgs: string[] } | undefined {\n\tif (isExecutableOnPath(\"hooteams\", env)) return { command: \"hooteams\", prefixArgs: [] };\n\tif (isExecutableOnPath(\"bunx\", env)) return { command: \"bunx\", prefixArgs: [\"hooteams\"] };\n\treturn undefined;\n}\n\nexport interface AutoTeam {\n\t/** Base URL of the spawned hooteams server. */\n\turl: string;\n\t/** Graceful shutdown: POST /stop, then kill the child's process group. */\n\tstop(): Promise<void>;\n}\n\nexport interface AutoTeamOptions {\n\t/** Startup progress sink (pre-TUI, so console is fine). */\n\tlog?: (message: string) => void;\n\t/** How long to wait for GET /health (default 15s). */\n\thealthTimeoutMs?: number;\n\tenv?: NodeJS.ProcessEnv;\n}\n\nfunction killChild(child: ChildProcess): void {\n\tif (child.pid === undefined || child.exitCode !== null || child.signalCode !== null) return;\n\ttry {\n\t\t// POSIX: the child leads its own process group (detached), so a negative\n\t\t// pid reaches hooteams even when launched through a bunx wrapper.\n\t\tif (process.platform !== \"win32\") process.kill(-child.pid, \"SIGTERM\");\n\t\telse child.kill();\n\t} catch {\n\t\t// Already gone.\n\t}\n}\n\nasync function waitForHealth(url: string, child: ChildProcess, timeoutMs: number): Promise<void> {\n\tconst deadline = Date.now() + timeoutMs;\n\twhile (Date.now() < deadline) {\n\t\tif (child.exitCode !== null || child.signalCode !== null) {\n\t\t\tthrow new Error(`--team auto: hooteams exited (code ${child.exitCode ?? \"signal\"}) before becoming healthy`);\n\t\t}\n\t\ttry {\n\t\t\tconst response = await fetch(`${url}/health`, { signal: AbortSignal.timeout(1000) });\n\t\t\tif (response.ok) {\n\t\t\t\tconst body = (await response.json()) as { ok?: boolean };\n\t\t\t\tif (body.ok === true) return;\n\t\t\t}\n\t\t} catch {\n\t\t\t// Not up yet; keep polling.\n\t\t}\n\t\tawait new Promise((resolve) => setTimeout(resolve, 150));\n\t}\n\tthrow new Error(`--team auto: hooteams did not report healthy at ${url}/health within ${timeoutMs}ms`);\n}\n\n/**\n * Resolve the config, spawn hooteams on a free port, and wait for /health.\n * Throws (with a message ready for the terminal) when no config is found, no\n * launcher resolves, or the server never becomes healthy.\n */\nexport async function startAutoTeam(cwd: string, options: AutoTeamOptions = {}): Promise<AutoTeam> {\n\tconst env = options.env ?? process.env;\n\tconst config = findTeamConfig(cwd);\n\tif (!config) {\n\t\tthrow new Error(\n\t\t\t`--team auto: no team config found. Looked for ${TEAM_CONFIG_CANDIDATES.join(\" or \")} in ${cwd} and every parent directory.`,\n\t\t);\n\t}\n\tconst launcher = resolveHooteamsLauncher(env);\n\tif (!launcher) {\n\t\tthrow new Error(\n\t\t\t\"--team auto: hooteams is not on PATH and bunx is unavailable. Install hooteams (or bun) or pass --team <url> to use a running server.\",\n\t\t);\n\t}\n\n\tconst port = await findFreePort();\n\tconst url = `http://localhost:${port}`;\n\toptions.log?.(`Starting hooteams (config ${config}) on port ${port}…`);\n\n\tconst child = spawn(\n\t\tlauncher.command,\n\t\t[...launcher.prefixArgs, \"start\", \"--config\", config, \"--port\", String(port)],\n\t\t{\n\t\t\tstdio: \"ignore\",\n\t\t\tenv,\n\t\t\tdetached: process.platform !== \"win32\",\n\t\t},\n\t);\n\tchild.unref();\n\tconst reapOnExit = () => killChild(child);\n\tprocess.on(\"exit\", reapOnExit);\n\n\ttry {\n\t\tawait waitForHealth(url, child, options.healthTimeoutMs ?? 15000);\n\t} catch (error) {\n\t\tprocess.off(\"exit\", reapOnExit);\n\t\tkillChild(child);\n\t\tthrow error;\n\t}\n\n\treturn {\n\t\turl,\n\t\tasync stop() {\n\t\t\tprocess.off(\"exit\", reapOnExit);\n\t\t\ttry {\n\t\t\t\tawait fetch(`${url}/stop`, { method: \"POST\", signal: AbortSignal.timeout(2000) });\n\t\t\t} catch {\n\t\t\t\t// Graceful stop is best-effort; the kill below is the guarantee.\n\t\t\t}\n\t\t\tkillChild(child);\n\t\t},\n\t};\n}\n"]}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `--team auto`: discover a team config, spawn a local hooteams server as a
|
|
3
|
+
* child process, and hand back its URL so the rest of the pipeline behaves
|
|
4
|
+
* exactly as if `--team http://localhost:<port>` had been passed.
|
|
5
|
+
*
|
|
6
|
+
* hooteams is intentionally not bundled — the launcher is resolved from PATH
|
|
7
|
+
* (`hooteams`, falling back to `bunx hooteams`) and missing pieces fail with
|
|
8
|
+
* a clear, actionable error. The child is reaped on hoocode exit, clean or
|
|
9
|
+
* signalled, via a process "exit" hook (the interactive shutdown path calls
|
|
10
|
+
* process.exit directly, so an async cleanup would never run).
|
|
11
|
+
*/
|
|
12
|
+
import { spawn } from "node:child_process";
|
|
13
|
+
import { accessSync, constants, existsSync } from "node:fs";
|
|
14
|
+
import { createServer } from "node:net";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
/** Config locations probed at each directory level, in priority order. */
|
|
17
|
+
export const TEAM_CONFIG_CANDIDATES = [path.join(".agents", "teams", "default.json"), "hooteams.config.json"];
|
|
18
|
+
/**
|
|
19
|
+
* Walk up from startDir to the filesystem root, returning the first config
|
|
20
|
+
* found. Both candidates are probed per level (.agents/teams/default.json
|
|
21
|
+
* wins over hooteams.config.json in the same directory).
|
|
22
|
+
*/
|
|
23
|
+
export function findTeamConfig(startDir) {
|
|
24
|
+
let dir = path.resolve(startDir);
|
|
25
|
+
while (true) {
|
|
26
|
+
for (const candidate of TEAM_CONFIG_CANDIDATES) {
|
|
27
|
+
const candidatePath = path.join(dir, candidate);
|
|
28
|
+
if (existsSync(candidatePath))
|
|
29
|
+
return candidatePath;
|
|
30
|
+
}
|
|
31
|
+
const parent = path.dirname(dir);
|
|
32
|
+
if (parent === dir)
|
|
33
|
+
return undefined;
|
|
34
|
+
dir = parent;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/** Ask the OS for a free port by binding port 0 and reading the assignment. */
|
|
38
|
+
export function findFreePort() {
|
|
39
|
+
return new Promise((resolve, reject) => {
|
|
40
|
+
const server = createServer();
|
|
41
|
+
server.once("error", reject);
|
|
42
|
+
server.listen(0, "127.0.0.1", () => {
|
|
43
|
+
const address = server.address();
|
|
44
|
+
if (address === null || typeof address === "string") {
|
|
45
|
+
server.close(() => reject(new Error("could not determine a free port")));
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
const { port } = address;
|
|
49
|
+
server.close(() => resolve(port));
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
function isExecutableOnPath(name, env) {
|
|
54
|
+
const pathVar = env.PATH ?? "";
|
|
55
|
+
const extensions = process.platform === "win32" ? (env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";") : [""];
|
|
56
|
+
for (const dir of pathVar.split(path.delimiter)) {
|
|
57
|
+
if (!dir)
|
|
58
|
+
continue;
|
|
59
|
+
for (const extension of extensions) {
|
|
60
|
+
try {
|
|
61
|
+
accessSync(path.join(dir, name + extension.toLowerCase()), constants.X_OK);
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
// keep probing
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
/** How to launch hooteams: directly from PATH, or through bunx. */
|
|
72
|
+
export function resolveHooteamsLauncher(env = process.env) {
|
|
73
|
+
if (isExecutableOnPath("hooteams", env))
|
|
74
|
+
return { command: "hooteams", prefixArgs: [] };
|
|
75
|
+
if (isExecutableOnPath("bunx", env))
|
|
76
|
+
return { command: "bunx", prefixArgs: ["hooteams"] };
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
function killChild(child) {
|
|
80
|
+
if (child.pid === undefined || child.exitCode !== null || child.signalCode !== null)
|
|
81
|
+
return;
|
|
82
|
+
try {
|
|
83
|
+
// POSIX: the child leads its own process group (detached), so a negative
|
|
84
|
+
// pid reaches hooteams even when launched through a bunx wrapper.
|
|
85
|
+
if (process.platform !== "win32")
|
|
86
|
+
process.kill(-child.pid, "SIGTERM");
|
|
87
|
+
else
|
|
88
|
+
child.kill();
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
// Already gone.
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
async function waitForHealth(url, child, timeoutMs) {
|
|
95
|
+
const deadline = Date.now() + timeoutMs;
|
|
96
|
+
while (Date.now() < deadline) {
|
|
97
|
+
if (child.exitCode !== null || child.signalCode !== null) {
|
|
98
|
+
throw new Error(`--team auto: hooteams exited (code ${child.exitCode ?? "signal"}) before becoming healthy`);
|
|
99
|
+
}
|
|
100
|
+
try {
|
|
101
|
+
const response = await fetch(`${url}/health`, { signal: AbortSignal.timeout(1000) });
|
|
102
|
+
if (response.ok) {
|
|
103
|
+
const body = (await response.json());
|
|
104
|
+
if (body.ok === true)
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
// Not up yet; keep polling.
|
|
110
|
+
}
|
|
111
|
+
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
112
|
+
}
|
|
113
|
+
throw new Error(`--team auto: hooteams did not report healthy at ${url}/health within ${timeoutMs}ms`);
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Resolve the config, spawn hooteams on a free port, and wait for /health.
|
|
117
|
+
* Throws (with a message ready for the terminal) when no config is found, no
|
|
118
|
+
* launcher resolves, or the server never becomes healthy.
|
|
119
|
+
*/
|
|
120
|
+
export async function startAutoTeam(cwd, options = {}) {
|
|
121
|
+
const env = options.env ?? process.env;
|
|
122
|
+
const config = findTeamConfig(cwd);
|
|
123
|
+
if (!config) {
|
|
124
|
+
throw new Error(`--team auto: no team config found. Looked for ${TEAM_CONFIG_CANDIDATES.join(" or ")} in ${cwd} and every parent directory.`);
|
|
125
|
+
}
|
|
126
|
+
const launcher = resolveHooteamsLauncher(env);
|
|
127
|
+
if (!launcher) {
|
|
128
|
+
throw new Error("--team auto: hooteams is not on PATH and bunx is unavailable. Install hooteams (or bun) or pass --team <url> to use a running server.");
|
|
129
|
+
}
|
|
130
|
+
const port = await findFreePort();
|
|
131
|
+
const url = `http://localhost:${port}`;
|
|
132
|
+
options.log?.(`Starting hooteams (config ${config}) on port ${port}…`);
|
|
133
|
+
const child = spawn(launcher.command, [...launcher.prefixArgs, "start", "--config", config, "--port", String(port)], {
|
|
134
|
+
stdio: "ignore",
|
|
135
|
+
env,
|
|
136
|
+
detached: process.platform !== "win32",
|
|
137
|
+
});
|
|
138
|
+
child.unref();
|
|
139
|
+
const reapOnExit = () => killChild(child);
|
|
140
|
+
process.on("exit", reapOnExit);
|
|
141
|
+
try {
|
|
142
|
+
await waitForHealth(url, child, options.healthTimeoutMs ?? 15000);
|
|
143
|
+
}
|
|
144
|
+
catch (error) {
|
|
145
|
+
process.off("exit", reapOnExit);
|
|
146
|
+
killChild(child);
|
|
147
|
+
throw error;
|
|
148
|
+
}
|
|
149
|
+
return {
|
|
150
|
+
url,
|
|
151
|
+
async stop() {
|
|
152
|
+
process.off("exit", reapOnExit);
|
|
153
|
+
try {
|
|
154
|
+
await fetch(`${url}/stop`, { method: "POST", signal: AbortSignal.timeout(2000) });
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
// Graceful stop is best-effort; the kill below is the guarantee.
|
|
158
|
+
}
|
|
159
|
+
killChild(child);
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
//# sourceMappingURL=team-auto.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"team-auto.js","sourceRoot":"","sources":["../../src/core/team-auto.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * `--team auto`: discover a team config, spawn a local hooteams server as a\n * child process, and hand back its URL so the rest of the pipeline behaves\n * exactly as if `--team http://localhost:<port>` had been passed.\n *\n * hooteams is intentionally not bundled — the launcher is resolved from PATH\n * (`hooteams`, falling back to `bunx hooteams`) and missing pieces fail with\n * a clear, actionable error. The child is reaped on hoocode exit, clean or\n * signalled, via a process \"exit\" hook (the interactive shutdown path calls\n * process.exit directly, so an async cleanup would never run).\n */\n\nimport { type ChildProcess, spawn } from \"node:child_process\";\nimport { accessSync, constants, existsSync } from \"node:fs\";\nimport { createServer } from \"node:net\";\nimport path from \"node:path\";\n\n/** Config locations probed at each directory level, in priority order. */\nexport const TEAM_CONFIG_CANDIDATES = [path.join(\".agents\", \"teams\", \"default.json\"), \"hooteams.config.json\"];\n\n/**\n * Walk up from startDir to the filesystem root, returning the first config\n * found. Both candidates are probed per level (.agents/teams/default.json\n * wins over hooteams.config.json in the same directory).\n */\nexport function findTeamConfig(startDir: string): string | undefined {\n\tlet dir = path.resolve(startDir);\n\twhile (true) {\n\t\tfor (const candidate of TEAM_CONFIG_CANDIDATES) {\n\t\t\tconst candidatePath = path.join(dir, candidate);\n\t\t\tif (existsSync(candidatePath)) return candidatePath;\n\t\t}\n\t\tconst parent = path.dirname(dir);\n\t\tif (parent === dir) return undefined;\n\t\tdir = parent;\n\t}\n}\n\n/** Ask the OS for a free port by binding port 0 and reading the assignment. */\nexport function findFreePort(): Promise<number> {\n\treturn new Promise((resolve, reject) => {\n\t\tconst server = createServer();\n\t\tserver.once(\"error\", reject);\n\t\tserver.listen(0, \"127.0.0.1\", () => {\n\t\t\tconst address = server.address();\n\t\t\tif (address === null || typeof address === \"string\") {\n\t\t\t\tserver.close(() => reject(new Error(\"could not determine a free port\")));\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tconst { port } = address;\n\t\t\tserver.close(() => resolve(port));\n\t\t});\n\t});\n}\n\nfunction isExecutableOnPath(name: string, env: NodeJS.ProcessEnv): boolean {\n\tconst pathVar = env.PATH ?? \"\";\n\tconst extensions = process.platform === \"win32\" ? (env.PATHEXT ?? \".EXE;.CMD;.BAT;.COM\").split(\";\") : [\"\"];\n\tfor (const dir of pathVar.split(path.delimiter)) {\n\t\tif (!dir) continue;\n\t\tfor (const extension of extensions) {\n\t\t\ttry {\n\t\t\t\taccessSync(path.join(dir, name + extension.toLowerCase()), constants.X_OK);\n\t\t\t\treturn true;\n\t\t\t} catch {\n\t\t\t\t// keep probing\n\t\t\t}\n\t\t}\n\t}\n\treturn false;\n}\n\n/** How to launch hooteams: directly from PATH, or through bunx. */\nexport function resolveHooteamsLauncher(\n\tenv: NodeJS.ProcessEnv = process.env,\n): { command: string; prefixArgs: string[] } | undefined {\n\tif (isExecutableOnPath(\"hooteams\", env)) return { command: \"hooteams\", prefixArgs: [] };\n\tif (isExecutableOnPath(\"bunx\", env)) return { command: \"bunx\", prefixArgs: [\"hooteams\"] };\n\treturn undefined;\n}\n\nexport interface AutoTeam {\n\t/** Base URL of the spawned hooteams server. */\n\turl: string;\n\t/** Graceful shutdown: POST /stop, then kill the child's process group. */\n\tstop(): Promise<void>;\n}\n\nexport interface AutoTeamOptions {\n\t/** Startup progress sink (pre-TUI, so console is fine). */\n\tlog?: (message: string) => void;\n\t/** How long to wait for GET /health (default 15s). */\n\thealthTimeoutMs?: number;\n\tenv?: NodeJS.ProcessEnv;\n}\n\nfunction killChild(child: ChildProcess): void {\n\tif (child.pid === undefined || child.exitCode !== null || child.signalCode !== null) return;\n\ttry {\n\t\t// POSIX: the child leads its own process group (detached), so a negative\n\t\t// pid reaches hooteams even when launched through a bunx wrapper.\n\t\tif (process.platform !== \"win32\") process.kill(-child.pid, \"SIGTERM\");\n\t\telse child.kill();\n\t} catch {\n\t\t// Already gone.\n\t}\n}\n\nasync function waitForHealth(url: string, child: ChildProcess, timeoutMs: number): Promise<void> {\n\tconst deadline = Date.now() + timeoutMs;\n\twhile (Date.now() < deadline) {\n\t\tif (child.exitCode !== null || child.signalCode !== null) {\n\t\t\tthrow new Error(`--team auto: hooteams exited (code ${child.exitCode ?? \"signal\"}) before becoming healthy`);\n\t\t}\n\t\ttry {\n\t\t\tconst response = await fetch(`${url}/health`, { signal: AbortSignal.timeout(1000) });\n\t\t\tif (response.ok) {\n\t\t\t\tconst body = (await response.json()) as { ok?: boolean };\n\t\t\t\tif (body.ok === true) return;\n\t\t\t}\n\t\t} catch {\n\t\t\t// Not up yet; keep polling.\n\t\t}\n\t\tawait new Promise((resolve) => setTimeout(resolve, 150));\n\t}\n\tthrow new Error(`--team auto: hooteams did not report healthy at ${url}/health within ${timeoutMs}ms`);\n}\n\n/**\n * Resolve the config, spawn hooteams on a free port, and wait for /health.\n * Throws (with a message ready for the terminal) when no config is found, no\n * launcher resolves, or the server never becomes healthy.\n */\nexport async function startAutoTeam(cwd: string, options: AutoTeamOptions = {}): Promise<AutoTeam> {\n\tconst env = options.env ?? process.env;\n\tconst config = findTeamConfig(cwd);\n\tif (!config) {\n\t\tthrow new Error(\n\t\t\t`--team auto: no team config found. Looked for ${TEAM_CONFIG_CANDIDATES.join(\" or \")} in ${cwd} and every parent directory.`,\n\t\t);\n\t}\n\tconst launcher = resolveHooteamsLauncher(env);\n\tif (!launcher) {\n\t\tthrow new Error(\n\t\t\t\"--team auto: hooteams is not on PATH and bunx is unavailable. Install hooteams (or bun) or pass --team <url> to use a running server.\",\n\t\t);\n\t}\n\n\tconst port = await findFreePort();\n\tconst url = `http://localhost:${port}`;\n\toptions.log?.(`Starting hooteams (config ${config}) on port ${port}…`);\n\n\tconst child = spawn(\n\t\tlauncher.command,\n\t\t[...launcher.prefixArgs, \"start\", \"--config\", config, \"--port\", String(port)],\n\t\t{\n\t\t\tstdio: \"ignore\",\n\t\t\tenv,\n\t\t\tdetached: process.platform !== \"win32\",\n\t\t},\n\t);\n\tchild.unref();\n\tconst reapOnExit = () => killChild(child);\n\tprocess.on(\"exit\", reapOnExit);\n\n\ttry {\n\t\tawait waitForHealth(url, child, options.healthTimeoutMs ?? 15000);\n\t} catch (error) {\n\t\tprocess.off(\"exit\", reapOnExit);\n\t\tkillChild(child);\n\t\tthrow error;\n\t}\n\n\treturn {\n\t\turl,\n\t\tasync stop() {\n\t\t\tprocess.off(\"exit\", reapOnExit);\n\t\t\ttry {\n\t\t\t\tawait fetch(`${url}/stop`, { method: \"POST\", signal: AbortSignal.timeout(2000) });\n\t\t\t} catch {\n\t\t\t\t// Graceful stop is best-effort; the kill below is the guarantee.\n\t\t\t}\n\t\t\tkillChild(child);\n\t\t},\n\t};\n}\n"]}
|
package/dist/core/team-view.d.ts
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* hooteams team client (`--team <url>`).
|
|
3
3
|
*
|
|
4
4
|
* Connects to a running hooteams server, registers every role as a
|
|
5
5
|
* kind="role" agent in the task store, and maps the server's TeamEvent SSE
|
|
6
6
|
* stream onto task-store patches so the task panel's existing "teams" view
|
|
7
|
-
* shows live role state.
|
|
7
|
+
* shows live role state. On top of that mirror the connection exposes
|
|
8
|
+
* steering (POST /steer) and an event subscription used by the attach
|
|
9
|
+
* side-panel — both share the single /events stream; no second SSE
|
|
10
|
+
* connection is ever opened.
|
|
8
11
|
*
|
|
9
12
|
* The connection is best-effort by design — a connect failure or a later
|
|
10
13
|
* drop logs a warning and never blocks (or crashes) the main agent. At most
|
|
@@ -23,10 +26,41 @@ export interface TeamViewEvent {
|
|
|
23
26
|
agentId?: string;
|
|
24
27
|
ts?: number;
|
|
25
28
|
toolName?: string;
|
|
29
|
+
args?: unknown;
|
|
30
|
+
isError?: boolean;
|
|
31
|
+
/** Streaming assistant-message delta carried by message_update events. */
|
|
32
|
+
assistantMessageEvent?: {
|
|
33
|
+
type?: string;
|
|
34
|
+
delta?: string;
|
|
35
|
+
};
|
|
26
36
|
message?: {
|
|
27
37
|
role?: string;
|
|
28
38
|
errorMessage?: string;
|
|
39
|
+
usage?: {
|
|
40
|
+
input?: number;
|
|
41
|
+
output?: number;
|
|
42
|
+
cost?: {
|
|
43
|
+
total?: number;
|
|
44
|
+
};
|
|
45
|
+
};
|
|
29
46
|
};
|
|
47
|
+
/** Task lifecycle fields carried by task_* events from the orchestrator. */
|
|
48
|
+
taskId?: string;
|
|
49
|
+
/** Approval gate carried by task_paused. */
|
|
50
|
+
question?: string;
|
|
51
|
+
options?: string[];
|
|
52
|
+
/** Answer carried by task_resumed. */
|
|
53
|
+
chosenOption?: string;
|
|
54
|
+
/** "done" | "error" on task_finished. */
|
|
55
|
+
status?: string;
|
|
56
|
+
/** Run id carried by dag_complete / dag_failed. */
|
|
57
|
+
runId?: string;
|
|
58
|
+
}
|
|
59
|
+
/** One unanswered approval gate from GET /tasks/pending. */
|
|
60
|
+
export interface TeamPendingApproval {
|
|
61
|
+
taskId: string;
|
|
62
|
+
question: string;
|
|
63
|
+
options: string[];
|
|
30
64
|
}
|
|
31
65
|
/**
|
|
32
66
|
* Maps team status snapshots and TeamEvents onto task-store patches.
|
|
@@ -67,6 +101,28 @@ export interface TeamViewOptions {
|
|
|
67
101
|
export interface TeamViewConnection {
|
|
68
102
|
/** Close the SSE connection and stop reconnecting. */
|
|
69
103
|
stop(): void;
|
|
104
|
+
/** POST /steer { role, message }. Rejects on network or HTTP error. */
|
|
105
|
+
steer(role: string, message: string): Promise<void>;
|
|
106
|
+
/**
|
|
107
|
+
* Answer a paused task: POST /tasks/:taskId/resume { option, feedback? }.
|
|
108
|
+
* Rejects with "answered elsewhere" on 409 (first answer wins across
|
|
109
|
+
* surfaces) and with HTTP/network errors otherwise.
|
|
110
|
+
*/
|
|
111
|
+
resume(taskId: string, option: string, feedback?: string): Promise<void>;
|
|
112
|
+
/**
|
|
113
|
+
* GET /tasks/pending — gates that opened before we attached. Resolves to
|
|
114
|
+
* [] when the server has no active run (404) so callers need no special
|
|
115
|
+
* casing.
|
|
116
|
+
*/
|
|
117
|
+
pendingApprovals(): Promise<TeamPendingApproval[]>;
|
|
118
|
+
/**
|
|
119
|
+
* Subscribe to every TeamEvent delivered by the shared /events stream.
|
|
120
|
+
* Returns an unsubscribe function. Listeners receive events for all roles;
|
|
121
|
+
* per-role filtering is the subscriber's job (the attach panel filters).
|
|
122
|
+
*/
|
|
123
|
+
subscribe(listener: (event: TeamViewEvent) => void): () => void;
|
|
124
|
+
/** Number of live event subscribers. Exposed for leak tests. */
|
|
125
|
+
subscriberCount(): number;
|
|
70
126
|
}
|
|
71
127
|
/**
|
|
72
128
|
* Start the read-only team view against a hooteams server base URL.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"team-view.d.ts","sourceRoot":"","sources":["../../src/core/team-view.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,EAAwC,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAElF,uEAAuE;AACvE,MAAM,MAAM,kBAAkB,GAAG,MAAM,CAAC,MAAM,EAAE;IAAE,MAAM,CAAC,EAAE,MAAM,CAAC;IAAC,aAAa,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC,CAAC;AAE7F,+EAA+E;AAC/E,MAAM,WAAW,aAAa;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,OAAO,CAAC,EAAE;QAAE,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,YAAY,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;CACnD;AAyCD;;;;;;;;;;GAUG;AACH,qBAAa,cAAc;IAC1B,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAmB;IACzC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAA6B;IAErD,YAAY,KAAK,GAAE,OAAO,SAAqB,EAE9C;IAED,kDAAkD;IAClD,WAAW,CAAC,QAAQ,EAAE,kBAAkB,GAAG,IAAI,CAM9C;IAED,yDAAyD;IACzD,UAAU,CAAC,KAAK,EAAE,aAAa,GAAG,IAAI,CA4CrC;IAED,OAAO,CAAC,OAAO;IAIf;;;;OAIG;IACH,OAAO,CAAC,UAAU;IAYlB,OAAO,CAAC,SAAS;CAMjB;AAED,MAAM,WAAW,eAAe;IAC/B,+CAA+C;IAC/C,IAAI,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;IACjC,gCAAgC;IAChC,KAAK,CAAC,EAAE,OAAO,SAAS,CAAC;IACzB,6DAA6D;IAC7D,YAAY,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,kBAAkB;IAClC,sDAAsD;IACtD,IAAI,IAAI,IAAI,CAAC;CACb;AAID;;;;;GAKG;AACH,wBAAgB,eAAe,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,GAAE,eAAoB,GAAG,kBAAkB,CA4E9F","sourcesContent":["/**\n * Read-only hooteams team view (`--team <url>`).\n *\n * Connects to a running hooteams server, registers every role as a\n * kind=\"role\" agent in the task store, and maps the server's TeamEvent SSE\n * stream onto task-store patches so the task panel's existing \"teams\" view\n * shows live role state. Strictly observational: no steering, no attach.\n *\n * The connection is best-effort by design — a connect failure or a later\n * drop logs a warning and never blocks (or crashes) the main agent. At most\n * one SSE connection (to /events) is open at any time.\n */\n\nimport { type TaskAgentState, type TaskStatus, taskStore } from \"./task-store.js\";\n\n/** Shape of GET /status: coarse per-role status keyed by role name. */\nexport type TeamStatusSnapshot = Record<string, { status?: string; lastEventType?: string }>;\n\n/** One frame of GET /events: a hoocode AgentEvent tagged with its producer. */\nexport interface TeamViewEvent {\n\ttype: string;\n\trole: string;\n\tagentId?: string;\n\tts?: number;\n\ttoolName?: string;\n\tmessage?: { role?: string; errorMessage?: string };\n}\n\n/** hooteams AgentStatus word → task panel agent state. */\nfunction stateFromStatus(status: string | undefined): TaskAgentState {\n\tswitch (status) {\n\t\tcase \"idle\":\n\t\t\treturn \"idle\";\n\t\tcase \"thinking\":\n\t\tcase \"streaming\":\n\t\t\treturn \"active\";\n\t\tcase \"tool\":\n\t\t\treturn \"running\";\n\t\tcase \"done\":\n\t\t\treturn \"done\";\n\t\tcase \"error\":\n\t\t\treturn \"failed\";\n\t\tdefault:\n\t\t\treturn \"idle\";\n\t}\n}\n\nfunction taskStatusFromState(state: TaskAgentState): TaskStatus {\n\tswitch (state) {\n\t\tcase \"done\":\n\t\t\treturn \"done\";\n\t\tcase \"failed\":\n\t\t\treturn \"failed\";\n\t\tcase \"idle\":\n\t\t\t// Idle is settled, not queued: a \"pending\" task here would survive every\n\t\t\t// taskStore.reset() and pin the pane at \"working\" for the whole session.\n\t\t\treturn \"done\";\n\t\tdefault:\n\t\t\treturn \"in_progress\";\n\t}\n}\n\n/** Only these states represent activity worth a task row of its own. */\nfunction stateWarrantsTask(state: TaskAgentState): boolean {\n\treturn state === \"active\" || state === \"running\" || state === \"failed\";\n}\n\n/**\n * Maps team status snapshots and TeamEvents onto task-store patches.\n *\n * Each role owns one roster entry (id `team:<role>`) and at most one task whose\n * title tracks the role's latest activity. Tasks exist only while a role is\n * actually doing something (active/running, or failed so the error is visible);\n * idle roles keep their roster entry but no task — the panel's teams lens\n * renders them as placeholder groups, so a quiet team reads as an idle roster\n * instead of pinning the pane at \"working\". Entries are re-created on demand\n * because taskStore.reset() wipes finished tasks between user turns.\n */\nexport class TeamViewMapper {\n\tprivate readonly store: typeof taskStore;\n\tprivate readonly taskIds = new Map<string, number>();\n\n\tconstructor(store: typeof taskStore = taskStore) {\n\t\tthis.store = store;\n\t}\n\n\t/** Register roles from a GET /status snapshot. */\n\tapplyStatus(snapshot: TeamStatusSnapshot): void {\n\t\tfor (const [role, info] of Object.entries(snapshot)) {\n\t\t\tconst state = stateFromStatus(info?.status);\n\t\t\tthis.ensureRole(role, state, info?.lastEventType ?? \"connected\");\n\t\t\tthis.patchRole(role, state);\n\t\t}\n\t}\n\n\t/** Map one TeamEvent from GET /events onto the store. */\n\tapplyEvent(event: TeamViewEvent): void {\n\t\tif (!event || typeof event.role !== \"string\" || event.role.length === 0) return;\n\t\tconst role = event.role;\n\t\tswitch (event.type) {\n\t\t\tcase \"agent_start\":\n\t\t\tcase \"turn_start\":\n\t\t\t\tthis.ensureRole(role, \"active\", \"thinking\");\n\t\t\t\tthis.patchRole(role, \"active\", \"thinking\");\n\t\t\t\tbreak;\n\t\t\tcase \"message_start\":\n\t\t\tcase \"message_update\":\n\t\t\tcase \"message_end\":\n\t\t\t\tthis.ensureRole(role, \"active\", \"responding\");\n\t\t\t\tthis.patchRole(role, \"active\");\n\t\t\t\tbreak;\n\t\t\tcase \"tool_execution_start\":\n\t\t\t\tthis.ensureRole(role, \"running\", `tool: ${event.toolName ?? \"?\"}`);\n\t\t\t\tthis.patchRole(role, \"running\", `tool: ${event.toolName ?? \"?\"}`);\n\t\t\t\tbreak;\n\t\t\tcase \"tool_execution_end\":\n\t\t\t\tthis.ensureRole(role, \"active\", \"thinking\");\n\t\t\t\tthis.patchRole(role, \"active\", \"thinking\");\n\t\t\t\tbreak;\n\t\t\tcase \"turn_end\":\n\t\t\t\tif (event.message?.role === \"assistant\" && event.message.errorMessage) {\n\t\t\t\t\tthis.ensureRole(role, \"failed\", \"error\");\n\t\t\t\t\tthis.patchRole(role, \"failed\", \"error\");\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\tcase \"agent_end\": {\n\t\t\t\t// A failed run stays failed; agent_end only marks clean completions\n\t\t\t\t// (mirrors hooteams' own status tracking).\n\t\t\t\tconst failed = this.store.agents().find((a) => a.id === this.agentId(role))?.state === \"failed\";\n\t\t\t\tif (!failed) {\n\t\t\t\t\tthis.ensureRole(role, \"done\", \"idle\");\n\t\t\t\t\tthis.patchRole(role, \"done\", \"idle\");\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tdefault:\n\t\t\t\t// Unknown event types still prove the role exists.\n\t\t\t\tthis.ensureRole(role, \"idle\", event.type);\n\t\t\t\tbreak;\n\t\t}\n\t}\n\n\tprivate agentId(role: string): string {\n\t\treturn `team:${role}`;\n\t}\n\n\t/**\n\t * Make sure the role's roster entry exists, plus its task when the state\n\t * warrants one (reset() may have dropped both). Idle/done states never\n\t * create a task — only patch one that live activity already opened.\n\t */\n\tprivate ensureRole(role: string, state: TaskAgentState, title: string): void {\n\t\tconst id = this.agentId(role);\n\t\tthis.store.upsertAgent({ id, name: role, kind: \"role\", state });\n\t\tconst taskId = this.taskIds.get(role);\n\t\tconst existing = taskId !== undefined ? this.store.list().find((task) => task.id === taskId) : undefined;\n\t\tif (!existing && stateWarrantsTask(state)) {\n\t\t\tconst task = this.store.create(title, { agent: id });\n\t\t\tthis.store.update(task.id, { status: taskStatusFromState(state) });\n\t\t\tthis.taskIds.set(role, task.id);\n\t\t}\n\t}\n\n\tprivate patchRole(role: string, state: TaskAgentState, title?: string): void {\n\t\tthis.store.patchAgent(this.agentId(role), { state });\n\t\tconst taskId = this.taskIds.get(role);\n\t\tif (taskId === undefined) return;\n\t\tthis.store.update(taskId, { status: taskStatusFromState(state), ...(title !== undefined ? { title } : {}) });\n\t}\n}\n\nexport interface TeamViewOptions {\n\t/** Warning sink; defaults to console.error. */\n\twarn?: (message: string) => void;\n\t/** Store override for tests. */\n\tstore?: typeof taskStore;\n\t/** Delay between reconnect attempts in ms (default 5000). */\n\tretryDelayMs?: number;\n}\n\nexport interface TeamViewConnection {\n\t/** Close the SSE connection and stop reconnecting. */\n\tstop(): void;\n}\n\nconst STATUS_TIMEOUT_MS = 5000;\n\n/**\n * Start the read-only team view against a hooteams server base URL.\n *\n * Returns immediately; all network work happens in the background and any\n * failure is reported through `warn` without ever throwing.\n */\nexport function connectTeamView(url: string, options: TeamViewOptions = {}): TeamViewConnection {\n\tconst base = url.replace(/\\/+$/, \"\");\n\tconst warn = options.warn ?? ((message: string) => console.error(message));\n\tconst retryDelayMs = options.retryDelayMs ?? 5000;\n\tconst mapper = new TeamViewMapper(options.store);\n\tconst controller = new AbortController();\n\tlet stopped = false;\n\n\tconst run = async (): Promise<void> => {\n\t\t// 1. Status snapshot: register the current roles.\n\t\ttry {\n\t\t\tconst response = await fetch(`${base}/status`, {\n\t\t\t\tsignal: AbortSignal.any([controller.signal, AbortSignal.timeout(STATUS_TIMEOUT_MS)]),\n\t\t\t});\n\t\t\tif (!response.ok) throw new Error(`HTTP ${response.status}`);\n\t\t\tmapper.applyStatus((await response.json()) as TeamStatusSnapshot);\n\t\t} catch (error) {\n\t\t\tif (stopped) return;\n\t\t\twarn(`team view: failed to fetch ${base}/status (${String(error)}); continuing without the team view`);\n\t\t}\n\n\t\t// 2. Single SSE subscription, reconnecting on drops.\n\t\tlet announcedDrop = false;\n\t\twhile (!stopped) {\n\t\t\ttry {\n\t\t\t\tconst response = await fetch(`${base}/events`, { signal: controller.signal });\n\t\t\t\tif (!response.ok || !response.body) throw new Error(`HTTP ${response.status}`);\n\t\t\t\tconst reader = response.body.getReader();\n\t\t\t\tconst decoder = new TextDecoder();\n\t\t\t\tlet buffer = \"\";\n\t\t\t\twhile (true) {\n\t\t\t\t\tconst { value, done } = await reader.read();\n\t\t\t\t\tif (done) break;\n\t\t\t\t\t// Only a stream that actually delivers data counts as recovered. A 200\n\t\t\t\t\t// that closes immediately (e.g. a server that answers /events without\n\t\t\t\t\t// streaming) used to re-arm the warning and repeat it every retry.\n\t\t\t\t\tannouncedDrop = false;\n\t\t\t\t\tbuffer += decoder.decode(value, { stream: true });\n\t\t\t\t\tlet index = buffer.indexOf(\"\\n\\n\");\n\t\t\t\t\twhile (index !== -1) {\n\t\t\t\t\t\tconst frame = buffer.slice(0, index);\n\t\t\t\t\t\tbuffer = buffer.slice(index + 2);\n\t\t\t\t\t\tfor (const line of frame.split(\"\\n\")) {\n\t\t\t\t\t\t\tif (!line.startsWith(\"data:\")) continue;\n\t\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\t\tmapper.applyEvent(JSON.parse(line.slice(5).trim()) as TeamViewEvent);\n\t\t\t\t\t\t\t} catch {\n\t\t\t\t\t\t\t\t// Malformed frames are dropped; the stream stays up.\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t\tindex = buffer.indexOf(\"\\n\\n\");\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif (stopped) return;\n\t\t\t\tthrow new Error(\"stream ended\");\n\t\t\t} catch (error) {\n\t\t\t\tif (stopped || controller.signal.aborted) return;\n\t\t\t\tif (!announcedDrop) {\n\t\t\t\t\tannouncedDrop = true;\n\t\t\t\t\twarn(`team view: lost connection to ${base}/events (${String(error)}); retrying in background`);\n\t\t\t\t}\n\t\t\t\tawait new Promise((resolve) => setTimeout(resolve, retryDelayMs));\n\t\t\t}\n\t\t}\n\t};\n\n\tvoid run().catch((error) => {\n\t\tif (!stopped) warn(`team view: unexpected error (${String(error)})`);\n\t});\n\n\treturn {\n\t\tstop() {\n\t\t\tstopped = true;\n\t\t\tcontroller.abort();\n\t\t},\n\t};\n}\n"]}
|
|
1
|
+
{"version":3,"file":"team-view.d.ts","sourceRoot":"","sources":["../../src/core/team-view.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * hooteams team client (`--team <url>`).\n *\n * Connects to a running hooteams server, registers every role as a\n * kind=\"role\" agent in the task store, and maps the server's TeamEvent SSE\n * stream onto task-store patches so the task panel's existing \"teams\" view\n * shows live role state. On top of that mirror the connection exposes\n * steering (POST /steer) and an event subscription used by the attach\n * side-panel — both share the single /events stream; no second SSE\n * connection is ever opened.\n *\n * The connection is best-effort by design — a connect failure or a later\n * drop logs a warning and never blocks (or crashes) the main agent. At most\n * one SSE connection (to /events) is open at any time.\n */\n\nimport { type TaskAgentState, type TaskStatus, taskStore } from \"./task-store.js\";\n\n/** Shape of GET /status: coarse per-role status keyed by role name. */\nexport type TeamStatusSnapshot = Record<string, { status?: string; lastEventType?: string }>;\n\n/** One frame of GET /events: a hoocode AgentEvent tagged with its producer. */\nexport interface TeamViewEvent {\n\ttype: string;\n\trole: string;\n\tagentId?: string;\n\tts?: number;\n\ttoolName?: string;\n\targs?: unknown;\n\tisError?: boolean;\n\t/** Streaming assistant-message delta carried by message_update events. */\n\tassistantMessageEvent?: { type?: string; delta?: string };\n\tmessage?: {\n\t\trole?: string;\n\t\terrorMessage?: string;\n\t\tusage?: { input?: number; output?: number; cost?: { total?: number } };\n\t};\n\t/** Task lifecycle fields carried by task_* events from the orchestrator. */\n\ttaskId?: string;\n\t/** Approval gate carried by task_paused. */\n\tquestion?: string;\n\toptions?: string[];\n\t/** Answer carried by task_resumed. */\n\tchosenOption?: string;\n\t/** \"done\" | \"error\" on task_finished. */\n\tstatus?: string;\n\t/** Run id carried by dag_complete / dag_failed. */\n\trunId?: string;\n}\n\n/** One unanswered approval gate from GET /tasks/pending. */\nexport interface TeamPendingApproval {\n\ttaskId: string;\n\tquestion: string;\n\toptions: string[];\n}\n\n/** hooteams AgentStatus word → task panel agent state. */\nfunction stateFromStatus(status: string | undefined): TaskAgentState {\n\tswitch (status) {\n\t\tcase \"idle\":\n\t\t\treturn \"idle\";\n\t\tcase \"thinking\":\n\t\tcase \"streaming\":\n\t\t\treturn \"active\";\n\t\tcase \"tool\":\n\t\t\treturn \"running\";\n\t\tcase \"done\":\n\t\t\treturn \"done\";\n\t\tcase \"error\":\n\t\t\treturn \"failed\";\n\t\tcase \"paused\":\n\t\t\treturn \"waiting\";\n\t\tdefault:\n\t\t\treturn \"idle\";\n\t}\n}\n\nfunction taskStatusFromState(state: TaskAgentState): TaskStatus {\n\tswitch (state) {\n\t\tcase \"done\":\n\t\t\treturn \"done\";\n\t\tcase \"failed\":\n\t\t\treturn \"failed\";\n\t\tcase \"idle\":\n\t\t\t// Idle is settled, not queued: a \"pending\" task here would survive every\n\t\t\t// taskStore.reset() and pin the pane at \"working\" for the whole session.\n\t\t\treturn \"done\";\n\t\tdefault:\n\t\t\treturn \"in_progress\";\n\t}\n}\n\n/** Only these states represent activity worth a task row of its own. */\nfunction stateWarrantsTask(state: TaskAgentState): boolean {\n\treturn state === \"active\" || state === \"running\" || state === \"failed\" || state === \"waiting\";\n}\n\n/**\n * Maps team status snapshots and TeamEvents onto task-store patches.\n *\n * Each role owns one roster entry (id `team:<role>`) and at most one task whose\n * title tracks the role's latest activity. Tasks exist only while a role is\n * actually doing something (active/running, or failed so the error is visible);\n * idle roles keep their roster entry but no task — the panel's teams lens\n * renders them as placeholder groups, so a quiet team reads as an idle roster\n * instead of pinning the pane at \"working\". Entries are re-created on demand\n * because taskStore.reset() wipes finished tasks between user turns.\n */\nexport class TeamViewMapper {\n\tprivate readonly store: typeof taskStore;\n\tprivate readonly taskIds = new Map<string, number>();\n\n\tconstructor(store: typeof taskStore = taskStore) {\n\t\tthis.store = store;\n\t}\n\n\t/** Register roles from a GET /status snapshot. */\n\tapplyStatus(snapshot: TeamStatusSnapshot): void {\n\t\tfor (const [role, info] of Object.entries(snapshot)) {\n\t\t\tconst state = stateFromStatus(info?.status);\n\t\t\tthis.ensureRole(role, state, info?.lastEventType ?? \"connected\");\n\t\t\tthis.patchRole(role, state);\n\t\t}\n\t}\n\n\t/** Map one TeamEvent from GET /events onto the store. */\n\tapplyEvent(event: TeamViewEvent): void {\n\t\tif (!event || typeof event.role !== \"string\" || event.role.length === 0) return;\n\t\t// Dag settlement events are tagged role=\"orchestrator\" — they describe\n\t\t// the run, not a member, and must not create a phantom roster entry.\n\t\tif (event.type === \"dag_complete\" || event.type === \"dag_failed\") return;\n\t\tconst role = event.role;\n\t\tswitch (event.type) {\n\t\t\tcase \"agent_start\":\n\t\t\tcase \"turn_start\":\n\t\t\t\tthis.ensureRole(role, \"active\", \"thinking\");\n\t\t\t\tthis.patchRole(role, \"active\", \"thinking\");\n\t\t\t\tbreak;\n\t\t\tcase \"message_start\":\n\t\t\tcase \"message_update\":\n\t\t\tcase \"message_end\":\n\t\t\t\tthis.ensureRole(role, \"active\", \"responding\");\n\t\t\t\tthis.patchRole(role, \"active\");\n\t\t\t\tbreak;\n\t\t\tcase \"tool_execution_start\":\n\t\t\t\tthis.ensureRole(role, \"running\", `tool: ${event.toolName ?? \"?\"}`);\n\t\t\t\tthis.patchRole(role, \"running\", `tool: ${event.toolName ?? \"?\"}`);\n\t\t\t\tbreak;\n\t\t\tcase \"tool_execution_end\":\n\t\t\t\tthis.ensureRole(role, \"active\", \"thinking\");\n\t\t\t\tthis.patchRole(role, \"active\", \"thinking\");\n\t\t\t\tbreak;\n\t\t\tcase \"turn_end\":\n\t\t\t\tif (event.message?.role === \"assistant\" && event.message.errorMessage) {\n\t\t\t\t\tthis.ensureRole(role, \"failed\", \"error\");\n\t\t\t\t\tthis.patchRole(role, \"failed\", \"error\");\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\tcase \"agent_end\": {\n\t\t\t\t// A failed run stays failed; agent_end only marks clean completions\n\t\t\t\t// (mirrors hooteams' own status tracking).\n\t\t\t\tconst failed = this.store.agents().find((a) => a.id === this.agentId(role))?.state === \"failed\";\n\t\t\t\tif (!failed) {\n\t\t\t\t\tthis.ensureRole(role, \"done\", \"idle\");\n\t\t\t\t\tthis.patchRole(role, \"done\", \"idle\");\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcase \"task_started\":\n\t\t\t\tthis.ensureRole(role, \"active\", `task: ${event.taskId ?? \"?\"}`);\n\t\t\t\tthis.patchRole(role, \"active\", `task: ${event.taskId ?? \"?\"}`);\n\t\t\t\tbreak;\n\t\t\tcase \"task_paused\": {\n\t\t\t\tconst title = `awaiting approval: ${event.question ?? \"?\"}`;\n\t\t\t\tthis.ensureRole(role, \"waiting\", title);\n\t\t\t\tthis.patchRole(role, \"waiting\", title);\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcase \"task_resumed\":\n\t\t\t\tthis.ensureRole(role, \"active\", `task: ${event.taskId ?? \"?\"}`);\n\t\t\t\tthis.patchRole(role, \"active\", `task: ${event.taskId ?? \"?\"}`);\n\t\t\t\tbreak;\n\t\t\tcase \"task_finished\": {\n\t\t\t\tconst state = event.status === \"error\" ? \"failed\" : \"done\";\n\t\t\t\tthis.ensureRole(role, state, state === \"failed\" ? \"error\" : \"idle\");\n\t\t\t\tthis.patchRole(role, state, state === \"failed\" ? \"error\" : \"idle\");\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tdefault:\n\t\t\t\t// Unknown event types still prove the role exists.\n\t\t\t\tthis.ensureRole(role, \"idle\", event.type);\n\t\t\t\tbreak;\n\t\t}\n\t}\n\n\tprivate agentId(role: string): string {\n\t\treturn `team:${role}`;\n\t}\n\n\t/**\n\t * Make sure the role's roster entry exists, plus its task when the state\n\t * warrants one (reset() may have dropped both). Idle/done states never\n\t * create a task — only patch one that live activity already opened.\n\t */\n\tprivate ensureRole(role: string, state: TaskAgentState, title: string): void {\n\t\tconst id = this.agentId(role);\n\t\tthis.store.upsertAgent({ id, name: role, kind: \"role\", state });\n\t\tconst taskId = this.taskIds.get(role);\n\t\tconst existing = taskId !== undefined ? this.store.list().find((task) => task.id === taskId) : undefined;\n\t\tif (!existing && stateWarrantsTask(state)) {\n\t\t\tconst task = this.store.create(title, { agent: id });\n\t\t\tthis.store.update(task.id, { status: taskStatusFromState(state) });\n\t\t\tthis.taskIds.set(role, task.id);\n\t\t}\n\t}\n\n\tprivate patchRole(role: string, state: TaskAgentState, title?: string): void {\n\t\tthis.store.patchAgent(this.agentId(role), { state });\n\t\tconst taskId = this.taskIds.get(role);\n\t\tif (taskId === undefined) return;\n\t\tthis.store.update(taskId, { status: taskStatusFromState(state), ...(title !== undefined ? { title } : {}) });\n\t}\n}\n\nexport interface TeamViewOptions {\n\t/** Warning sink; defaults to console.error. */\n\twarn?: (message: string) => void;\n\t/** Store override for tests. */\n\tstore?: typeof taskStore;\n\t/** Delay between reconnect attempts in ms (default 5000). */\n\tretryDelayMs?: number;\n}\n\nexport interface TeamViewConnection {\n\t/** Close the SSE connection and stop reconnecting. */\n\tstop(): void;\n\t/** POST /steer { role, message }. Rejects on network or HTTP error. */\n\tsteer(role: string, message: string): Promise<void>;\n\t/**\n\t * Answer a paused task: POST /tasks/:taskId/resume { option, feedback? }.\n\t * Rejects with \"answered elsewhere\" on 409 (first answer wins across\n\t * surfaces) and with HTTP/network errors otherwise.\n\t */\n\tresume(taskId: string, option: string, feedback?: string): Promise<void>;\n\t/**\n\t * GET /tasks/pending — gates that opened before we attached. Resolves to\n\t * [] when the server has no active run (404) so callers need no special\n\t * casing.\n\t */\n\tpendingApprovals(): Promise<TeamPendingApproval[]>;\n\t/**\n\t * Subscribe to every TeamEvent delivered by the shared /events stream.\n\t * Returns an unsubscribe function. Listeners receive events for all roles;\n\t * per-role filtering is the subscriber's job (the attach panel filters).\n\t */\n\tsubscribe(listener: (event: TeamViewEvent) => void): () => void;\n\t/** Number of live event subscribers. Exposed for leak tests. */\n\tsubscriberCount(): number;\n}\n\nconst STATUS_TIMEOUT_MS = 5000;\nconst STEER_TIMEOUT_MS = 5000;\n\n/**\n * Start the read-only team view against a hooteams server base URL.\n *\n * Returns immediately; all network work happens in the background and any\n * failure is reported through `warn` without ever throwing.\n */\nexport function connectTeamView(url: string, options: TeamViewOptions = {}): TeamViewConnection {\n\tconst base = url.replace(/\\/+$/, \"\");\n\tconst warn = options.warn ?? ((message: string) => console.error(message));\n\tconst retryDelayMs = options.retryDelayMs ?? 5000;\n\tconst mapper = new TeamViewMapper(options.store);\n\tconst controller = new AbortController();\n\tconst listeners = new Set<(event: TeamViewEvent) => void>();\n\tlet stopped = false;\n\n\tconst deliver = (event: TeamViewEvent): void => {\n\t\tmapper.applyEvent(event);\n\t\tfor (const listener of listeners) {\n\t\t\ttry {\n\t\t\t\tlistener(event);\n\t\t\t} catch {\n\t\t\t\t// A broken subscriber must not take down the stream or its peers.\n\t\t\t}\n\t\t}\n\t};\n\n\tconst run = async (): Promise<void> => {\n\t\t// 1. Status snapshot: register the current roles.\n\t\ttry {\n\t\t\tconst response = await fetch(`${base}/status`, {\n\t\t\t\tsignal: AbortSignal.any([controller.signal, AbortSignal.timeout(STATUS_TIMEOUT_MS)]),\n\t\t\t});\n\t\t\tif (!response.ok) throw new Error(`HTTP ${response.status}`);\n\t\t\tmapper.applyStatus((await response.json()) as TeamStatusSnapshot);\n\t\t} catch (error) {\n\t\t\tif (stopped) return;\n\t\t\twarn(`team view: failed to fetch ${base}/status (${String(error)}); continuing without the team view`);\n\t\t}\n\n\t\t// 2. Single SSE subscription, reconnecting on drops.\n\t\tlet announcedDrop = false;\n\t\twhile (!stopped) {\n\t\t\ttry {\n\t\t\t\tconst response = await fetch(`${base}/events`, { signal: controller.signal });\n\t\t\t\tif (!response.ok || !response.body) throw new Error(`HTTP ${response.status}`);\n\t\t\t\tconst reader = response.body.getReader();\n\t\t\t\tconst decoder = new TextDecoder();\n\t\t\t\tlet buffer = \"\";\n\t\t\t\twhile (true) {\n\t\t\t\t\tconst { value, done } = await reader.read();\n\t\t\t\t\tif (done) break;\n\t\t\t\t\t// Only a stream that actually delivers data counts as recovered. A 200\n\t\t\t\t\t// that closes immediately (e.g. a server that answers /events without\n\t\t\t\t\t// streaming) used to re-arm the warning and repeat it every retry.\n\t\t\t\t\tannouncedDrop = false;\n\t\t\t\t\tbuffer += decoder.decode(value, { stream: true });\n\t\t\t\t\tlet index = buffer.indexOf(\"\\n\\n\");\n\t\t\t\t\twhile (index !== -1) {\n\t\t\t\t\t\tconst frame = buffer.slice(0, index);\n\t\t\t\t\t\tbuffer = buffer.slice(index + 2);\n\t\t\t\t\t\tfor (const line of frame.split(\"\\n\")) {\n\t\t\t\t\t\t\tif (!line.startsWith(\"data:\")) continue;\n\t\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\t\tdeliver(JSON.parse(line.slice(5).trim()) as TeamViewEvent);\n\t\t\t\t\t\t\t} catch {\n\t\t\t\t\t\t\t\t// Malformed frames are dropped; the stream stays up.\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t\tindex = buffer.indexOf(\"\\n\\n\");\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif (stopped) return;\n\t\t\t\tthrow new Error(\"stream ended\");\n\t\t\t} catch (error) {\n\t\t\t\tif (stopped || controller.signal.aborted) return;\n\t\t\t\tif (!announcedDrop) {\n\t\t\t\t\tannouncedDrop = true;\n\t\t\t\t\twarn(`team view: lost connection to ${base}/events (${String(error)}); retrying in background`);\n\t\t\t\t}\n\t\t\t\tawait new Promise((resolve) => setTimeout(resolve, retryDelayMs));\n\t\t\t}\n\t\t}\n\t};\n\n\tvoid run().catch((error) => {\n\t\tif (!stopped) warn(`team view: unexpected error (${String(error)})`);\n\t});\n\n\treturn {\n\t\tstop() {\n\t\t\tstopped = true;\n\t\t\tlisteners.clear();\n\t\t\tcontroller.abort();\n\t\t},\n\t\tasync steer(role: string, message: string): Promise<void> {\n\t\t\tconst response = await fetch(`${base}/steer`, {\n\t\t\t\tmethod: \"POST\",\n\t\t\t\theaders: { \"content-type\": \"application/json\" },\n\t\t\t\tbody: JSON.stringify({ role, message }),\n\t\t\t\tsignal: AbortSignal.any([controller.signal, AbortSignal.timeout(STEER_TIMEOUT_MS)]),\n\t\t\t});\n\t\t\tif (!response.ok) throw new Error(`HTTP ${response.status}`);\n\t\t},\n\t\tasync resume(taskId: string, option: string, feedback?: string): Promise<void> {\n\t\t\tconst response = await fetch(`${base}/tasks/${encodeURIComponent(taskId)}/resume`, {\n\t\t\t\tmethod: \"POST\",\n\t\t\t\theaders: { \"content-type\": \"application/json\" },\n\t\t\t\tbody: JSON.stringify({ option, ...(feedback !== undefined ? { feedback } : {}) }),\n\t\t\t\tsignal: AbortSignal.any([controller.signal, AbortSignal.timeout(STEER_TIMEOUT_MS)]),\n\t\t\t});\n\t\t\tif (response.status === 409) throw new Error(`task \"${taskId}\" was answered elsewhere`);\n\t\t\tif (!response.ok) throw new Error(`HTTP ${response.status}`);\n\t\t},\n\t\tasync pendingApprovals(): Promise<TeamPendingApproval[]> {\n\t\t\tconst response = await fetch(`${base}/tasks/pending`, {\n\t\t\t\tsignal: AbortSignal.any([controller.signal, AbortSignal.timeout(STATUS_TIMEOUT_MS)]),\n\t\t\t});\n\t\t\tif (response.status === 404) return [];\n\t\t\tif (!response.ok) throw new Error(`HTTP ${response.status}`);\n\t\t\tconst body = (await response.json()) as { pending?: TeamPendingApproval[] };\n\t\t\treturn Array.isArray(body.pending) ? body.pending : [];\n\t\t},\n\t\tsubscribe(listener: (event: TeamViewEvent) => void): () => void {\n\t\t\tlisteners.add(listener);\n\t\t\treturn () => {\n\t\t\t\tlisteners.delete(listener);\n\t\t\t};\n\t\t},\n\t\tsubscriberCount(): number {\n\t\t\treturn listeners.size;\n\t\t},\n\t};\n}\n"]}
|