@kolisachint/hoocode-agent 0.5.22 → 0.5.24
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 +47 -0
- package/dist/config.d.ts +11 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +18 -0
- package/dist/config.js.map +1 -1
- package/dist/core/canvas/discovery.d.ts +60 -0
- package/dist/core/canvas/discovery.d.ts.map +1 -0
- package/dist/core/canvas/discovery.js +83 -0
- package/dist/core/canvas/discovery.js.map +1 -0
- package/dist/core/canvas/launch.d.ts +90 -0
- package/dist/core/canvas/launch.d.ts.map +1 -0
- package/dist/core/canvas/launch.js +176 -0
- package/dist/core/canvas/launch.js.map +1 -0
- package/dist/core/canvas/protocol.d.ts +229 -0
- package/dist/core/canvas/protocol.d.ts.map +1 -0
- package/dist/core/canvas/protocol.js +129 -0
- package/dist/core/canvas/protocol.js.map +1 -0
- package/dist/core/canvas/registry.d.ts +139 -0
- package/dist/core/canvas/registry.d.ts.map +1 -0
- package/dist/core/canvas/registry.js +275 -0
- package/dist/core/canvas/registry.js.map +1 -0
- package/dist/core/canvas/resolver.d.ts +26 -0
- package/dist/core/canvas/resolver.d.ts.map +1 -0
- package/dist/core/canvas/resolver.js +57 -0
- package/dist/core/canvas/resolver.js.map +1 -0
- package/dist/core/canvas/runner.d.ts +101 -0
- package/dist/core/canvas/runner.d.ts.map +1 -0
- package/dist/core/canvas/runner.js +196 -0
- package/dist/core/canvas/runner.js.map +1 -0
- package/dist/core/canvas/sdk-shim/dispatch.test-helpers.d.ts +23 -0
- package/dist/core/canvas/sdk-shim/dispatch.test-helpers.d.ts.map +1 -0
- package/dist/core/canvas/sdk-shim/dispatch.test-helpers.js +36 -0
- package/dist/core/canvas/sdk-shim/dispatch.test-helpers.js.map +1 -0
- package/dist/core/canvas/sdk-shim/index.d.ts +113 -0
- package/dist/core/canvas/sdk-shim/index.d.ts.map +1 -0
- package/dist/core/canvas/sdk-shim/index.js +184 -0
- package/dist/core/canvas/sdk-shim/index.js.map +1 -0
- package/dist/core/canvas/session.d.ts +112 -0
- package/dist/core/canvas/session.d.ts.map +1 -0
- package/dist/core/canvas/session.js +197 -0
- package/dist/core/canvas/session.js.map +1 -0
- package/dist/core/canvas/trust.d.ts +71 -0
- package/dist/core/canvas/trust.d.ts.map +1 -0
- package/dist/core/canvas/trust.js +88 -0
- package/dist/core/canvas/trust.js.map +1 -0
- package/dist/core/extensions/loader.d.ts.map +1 -1
- package/dist/core/extensions/loader.js +32 -21
- package/dist/core/extensions/loader.js.map +1 -1
- package/dist/core/extensions/plugins/trust.d.ts +21 -0
- package/dist/core/extensions/plugins/trust.d.ts.map +1 -1
- package/dist/core/extensions/plugins/trust.js +26 -0
- package/dist/core/extensions/plugins/trust.js.map +1 -1
- package/dist/core/tools/canvas.d.ts +55 -0
- package/dist/core/tools/canvas.d.ts.map +1 -0
- package/dist/core/tools/canvas.js +159 -0
- package/dist/core/tools/canvas.js.map +1 -0
- package/dist/extensions/core/canvas.d.ts +20 -0
- package/dist/extensions/core/canvas.d.ts.map +1 -0
- package/dist/extensions/core/canvas.js +192 -0
- package/dist/extensions/core/canvas.js.map +1 -0
- package/dist/extensions/core/hoo-core.d.ts +1 -0
- package/dist/extensions/core/hoo-core.d.ts.map +1 -1
- package/dist/extensions/core/hoo-core.js +3 -0
- package/dist/extensions/core/hoo-core.js.map +1 -1
- package/dist/utils/paths.d.ts +9 -0
- package/dist/utils/paths.d.ts.map +1 -1
- package/dist/utils/paths.js +16 -0
- package/dist/utils/paths.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 +5 -4
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two agent-facing canvas tools.
|
|
3
|
+
*
|
|
4
|
+
* Design: `docs/canvas-extensions-design.md` §11.5. Copilot names its own shape in
|
|
5
|
+
* the SDK types — `list_canvas_capabilities` to discover, `invoke_canvas_action` to
|
|
6
|
+
* invoke — and hoocode mirrors it, for its own reason as well as fidelity:
|
|
7
|
+
* `AGENTS.md` budgets the prompt at ~4,140 tokens with ~2,710 of it tool schemas,
|
|
8
|
+
* and every active tool's schema is re-sent on every request. One tool per open
|
|
9
|
+
* action would make that surface grow with how many canvases are open. Two fixed
|
|
10
|
+
* tools keep it flat.
|
|
11
|
+
*
|
|
12
|
+
* **There is deliberately no "open a canvas" tool.** Opening forks a process and
|
|
13
|
+
* binds a listening socket; that is a person's decision, gated by workspace trust
|
|
14
|
+
* (§5). The agent drives a surface a human has already opened. This also keeps the
|
|
15
|
+
* injection surface flat: a poisoned issue title rendered into a canvas can at most
|
|
16
|
+
* cause an action on an instance the person chose to open.
|
|
17
|
+
*
|
|
18
|
+
* These are optional tools, created only when canvas support is available and at
|
|
19
|
+
* least one canvas is open — so a repository without canvases pays nothing.
|
|
20
|
+
*/
|
|
21
|
+
import { Type } from "typebox";
|
|
22
|
+
import { defineTool } from "../extensions/types.js";
|
|
23
|
+
/** Tool name for capability discovery, matching Copilot's. */
|
|
24
|
+
export const LIST_CANVAS_CAPABILITIES_TOOL_NAME = "list_canvas_capabilities";
|
|
25
|
+
/** Tool name for action invocation, matching Copilot's. */
|
|
26
|
+
export const INVOKE_CANVAS_ACTION_TOOL_NAME = "invoke_canvas_action";
|
|
27
|
+
/**
|
|
28
|
+
* Ceiling on a serialized action result, in characters.
|
|
29
|
+
*
|
|
30
|
+
* Whatever an action returns lands in the model's context window.
|
|
31
|
+
* `pr-artifact-explorer` truncates its own payloads (`entries.slice(0, 200)`), but
|
|
32
|
+
* nothing in the contract obliges a canvas to, so the host caps it too.
|
|
33
|
+
*/
|
|
34
|
+
export const CANVAS_RESULT_MAX_CHARS = 8_000;
|
|
35
|
+
const listParams = Type.Object({}, { additionalProperties: false });
|
|
36
|
+
const invokeParams = Type.Object({
|
|
37
|
+
instanceId: Type.String({ description: "From list_canvas_capabilities." }),
|
|
38
|
+
action: Type.String({ description: "Action name declared by that instance's canvas." }),
|
|
39
|
+
input: Type.Optional(Type.Unknown({ description: "Action input, matching the action's declared schema." })),
|
|
40
|
+
}, { additionalProperties: false });
|
|
41
|
+
function textResult(text) {
|
|
42
|
+
return { content: [{ type: "text", text }] };
|
|
43
|
+
}
|
|
44
|
+
/** Serialize an action result, capped so a chatty canvas cannot flood the context. */
|
|
45
|
+
function renderResult(value) {
|
|
46
|
+
const serialized = value === undefined ? "null" : JSON.stringify(value, null, 1);
|
|
47
|
+
if (serialized.length <= CANVAS_RESULT_MAX_CHARS)
|
|
48
|
+
return { text: serialized, truncated: false };
|
|
49
|
+
return {
|
|
50
|
+
text: `${serialized.slice(0, CANVAS_RESULT_MAX_CHARS)}\n… truncated at ${CANVAS_RESULT_MAX_CHARS} characters.`,
|
|
51
|
+
truncated: true,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Discovery: every open instance, its canvas, and the actions it declares with
|
|
56
|
+
* their input schemas.
|
|
57
|
+
*
|
|
58
|
+
* Takes no parameters. A filter would add schema bytes on every request to save
|
|
59
|
+
* bytes in a response the model reads once.
|
|
60
|
+
*/
|
|
61
|
+
function createListCapabilitiesTool(registry) {
|
|
62
|
+
return defineTool({
|
|
63
|
+
name: LIST_CANVAS_CAPABILITIES_TOOL_NAME,
|
|
64
|
+
label: LIST_CANVAS_CAPABILITIES_TOOL_NAME,
|
|
65
|
+
description: "List the open canvases and the actions each one accepts, with their input schemas. Call this before invoke_canvas_action to learn the instanceId and the action's schema.",
|
|
66
|
+
promptSnippet: "Discover open canvases and the actions they accept",
|
|
67
|
+
parameters: listParams,
|
|
68
|
+
async execute() {
|
|
69
|
+
const instances = registry.listInstances();
|
|
70
|
+
const bindings = registry.activeActions();
|
|
71
|
+
if (instances.length === 0) {
|
|
72
|
+
return {
|
|
73
|
+
...textResult("No canvas is open. A person opens a canvas; you can then drive it."),
|
|
74
|
+
details: { instances: 0, actions: 0 },
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
const report = instances.map((instance) => ({
|
|
78
|
+
instanceId: instance.instanceId,
|
|
79
|
+
canvas: instance.canvasId,
|
|
80
|
+
extension: instance.extensionId,
|
|
81
|
+
title: instance.title,
|
|
82
|
+
status: instance.status,
|
|
83
|
+
actions: bindings
|
|
84
|
+
.filter((binding) => binding.instanceId === instance.instanceId)
|
|
85
|
+
.map((binding) => binding.action),
|
|
86
|
+
}));
|
|
87
|
+
return {
|
|
88
|
+
...textResult(JSON.stringify(report, null, 1)),
|
|
89
|
+
details: { instances: instances.length, actions: bindings.length },
|
|
90
|
+
};
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
/** Invocation: run one declared action against one open instance. */
|
|
95
|
+
function createInvokeActionTool(registry) {
|
|
96
|
+
return defineTool({
|
|
97
|
+
name: INVOKE_CANVAS_ACTION_TOOL_NAME,
|
|
98
|
+
label: INVOKE_CANVAS_ACTION_TOOL_NAME,
|
|
99
|
+
// The description deliberately makes no safety claim. An earlier version said
|
|
100
|
+
// actions "cannot edit files or run commands", which is false: a canvas
|
|
101
|
+
// extension is arbitrary Node code running with the user's privileges, and
|
|
102
|
+
// `pr-artifact-explorer` really does download artifacts to disk and call the
|
|
103
|
+
// GitHub API. Those side effects never pass hoocode's permission gate, because
|
|
104
|
+
// the gate sits in front of hoocode's own tools, not inside a forked
|
|
105
|
+
// extension — the workspace-trust gate (canvas/trust.ts) is the control here,
|
|
106
|
+
// not a sentence in a tool schema. Never tell the model a safety property the
|
|
107
|
+
// runtime does not enforce.
|
|
108
|
+
description: "Invoke an action on an open canvas. Actions are implemented by the canvas extension itself: an action may change what the person is looking at and can have side effects of its own, so read the action's description before calling it.",
|
|
109
|
+
promptSnippet: "Act on an open canvas the user is looking at",
|
|
110
|
+
parameters: invokeParams,
|
|
111
|
+
async execute(_toolCallId, params, signal) {
|
|
112
|
+
// instanceId is a UUID and unique across every canvas, so the model does not
|
|
113
|
+
// have to carry the extension and canvas ids too — the registry already knows
|
|
114
|
+
// which instance a given id belongs to.
|
|
115
|
+
const instance = registry.listInstances().find((open) => open.instanceId === params.instanceId);
|
|
116
|
+
if (!instance) {
|
|
117
|
+
// Tools report failure by throwing here, as the built-ins do; the loop turns
|
|
118
|
+
// a rejection into the model's tool result.
|
|
119
|
+
const open = registry.listInstances().map((other) => other.instanceId);
|
|
120
|
+
throw new Error(open.length === 0
|
|
121
|
+
? "No canvas is open, so there is nothing to act on."
|
|
122
|
+
: `No open canvas instance "${params.instanceId}". Open instances: ${open.join(", ")}.`);
|
|
123
|
+
}
|
|
124
|
+
try {
|
|
125
|
+
// Honour the turn's abort signal: without this, aborting a turn leaves the
|
|
126
|
+
// request running and its answer arriving for a turn nobody awaits.
|
|
127
|
+
const result = await registry.invokeAction(instance, params.action, params.input, { signal });
|
|
128
|
+
const { text, truncated } = renderResult(result);
|
|
129
|
+
return {
|
|
130
|
+
...textResult(text),
|
|
131
|
+
details: { instanceId: params.instanceId, action: params.action, truncated },
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
catch (cause) {
|
|
135
|
+
// Canvas handlers throw CanvasError with a machine-readable code, which the
|
|
136
|
+
// runner preserves across the process boundary as CanvasCallError.code. Only
|
|
137
|
+
// the message is rendered to the model, so fold the code into it rather than
|
|
138
|
+
// letting the typed-error intent (§8) stop at the tool boundary.
|
|
139
|
+
const code = cause instanceof Error && "code" in cause ? String(cause.code) : undefined;
|
|
140
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
141
|
+
throw new Error(code ? `${code}: ${message}` : message);
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* The canvas tools, or none.
|
|
148
|
+
*
|
|
149
|
+
* Returns an empty array while nothing is open, so the two schemas are absent from
|
|
150
|
+
* the prompt in the overwhelmingly common case of a repository with no canvases —
|
|
151
|
+
* the same reason `registry.activeActions()` is empty until an instance exists
|
|
152
|
+
* (§7). Callers re-derive this when the open set changes.
|
|
153
|
+
*/
|
|
154
|
+
export function createCanvasToolDefinitions(registry) {
|
|
155
|
+
if (registry.listInstances().length === 0)
|
|
156
|
+
return [];
|
|
157
|
+
return [createListCapabilitiesTool(registry), createInvokeActionTool(registry)];
|
|
158
|
+
}
|
|
159
|
+
//# sourceMappingURL=canvas.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"canvas.js","sourceRoot":"","sources":["../../../src/core/tools/canvas.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,OAAO,EAAe,IAAI,EAAE,MAAM,SAAS,CAAC;AAE5C,OAAO,EAAE,UAAU,EAAuB,MAAM,wBAAwB,CAAC;AAEzE,8DAA8D;AAC9D,MAAM,CAAC,MAAM,kCAAkC,GAAG,0BAA0B,CAAC;AAC7E,2DAA2D;AAC3D,MAAM,CAAC,MAAM,8BAA8B,GAAG,sBAAsB,CAAC;AAErE;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,uBAAuB,GAAG,KAAK,CAAC;AAE7C,MAAM,UAAU,GAAG,IAAI,CAAC,MAAM,CAAC,EAAE,EAAE,EAAE,oBAAoB,EAAE,KAAK,EAAE,CAAC,CAAC;AAEpE,MAAM,YAAY,GAAG,IAAI,CAAC,MAAM,CAC/B;IACC,UAAU,EAAE,IAAI,CAAC,MAAM,CAAC,EAAE,WAAW,EAAE,gCAAgC,EAAE,CAAC;IAC1E,MAAM,EAAE,IAAI,CAAC,MAAM,CAAC,EAAE,WAAW,EAAE,iDAAiD,EAAE,CAAC;IACvF,KAAK,EAAE,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,OAAO,CAAC,EAAE,WAAW,EAAE,sDAAsD,EAAE,CAAC,CAAC;CAC3G,EACD,EAAE,oBAAoB,EAAE,KAAK,EAAE,CAC/B,CAAC;AAiBF,SAAS,UAAU,CAAC,IAAY,EAAE;IACjC,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAe,EAAE,IAAI,EAAE,CAAC,EAAE,CAAC;AAAA,CACtD;AAED,sFAAsF;AACtF,SAAS,YAAY,CAAC,KAAc,EAAwC;IAC3E,MAAM,UAAU,GAAG,KAAK,KAAK,SAAS,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC,SAAS,CAAC,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC;IACjF,IAAI,UAAU,CAAC,MAAM,IAAI,uBAAuB;QAAE,OAAO,EAAE,IAAI,EAAE,UAAU,EAAE,SAAS,EAAE,KAAK,EAAE,CAAC;IAChG,OAAO;QACN,IAAI,EAAE,GAAG,UAAU,CAAC,KAAK,CAAC,CAAC,EAAE,uBAAuB,CAAC,sBAAoB,uBAAuB,cAAc;QAC9G,SAAS,EAAE,IAAI;KACf,CAAC;AAAA,CACF;AAED;;;;;;GAMG;AACH,SAAS,0BAA0B,CAAC,QAAwB,EAAkB;IAC7E,OAAO,UAAU,CAA+C;QAC/D,IAAI,EAAE,kCAAkC;QACxC,KAAK,EAAE,kCAAkC;QACzC,WAAW,EACV,2KAA2K;QAC5K,aAAa,EAAE,oDAAoD;QACnE,UAAU,EAAE,UAAU;QACtB,KAAK,CAAC,OAAO,GAAG;YACf,MAAM,SAAS,GAAG,QAAQ,CAAC,aAAa,EAAE,CAAC;YAC3C,MAAM,QAAQ,GAAG,QAAQ,CAAC,aAAa,EAAE,CAAC;YAC1C,IAAI,SAAS,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;gBAC5B,OAAO;oBACN,GAAG,UAAU,CAAC,oEAAoE,CAAC;oBACnF,OAAO,EAAE,EAAE,SAAS,EAAE,CAAC,EAAE,OAAO,EAAE,CAAC,EAAE;iBACrC,CAAC;YACH,CAAC;YACD,MAAM,MAAM,GAAG,SAAS,CAAC,GAAG,CAAC,CAAC,QAAQ,EAAE,EAAE,CAAC,CAAC;gBAC3C,UAAU,EAAE,QAAQ,CAAC,UAAU;gBAC/B,MAAM,EAAE,QAAQ,CAAC,QAAQ;gBACzB,SAAS,EAAE,QAAQ,CAAC,WAAW;gBAC/B,KAAK,EAAE,QAAQ,CAAC,KAAK;gBACrB,MAAM,EAAE,QAAQ,CAAC,MAAM;gBACvB,OAAO,EAAE,QAAQ;qBACf,MAAM,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,OAAO,CAAC,UAAU,KAAK,QAAQ,CAAC,UAAU,CAAC;qBAC/D,GAAG,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,OAAO,CAAC,MAAM,CAAC;aAClC,CAAC,CAAC,CAAC;YACJ,OAAO;gBACN,GAAG,UAAU,CAAC,IAAI,CAAC,SAAS,CAAC,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC;gBAC9C,OAAO,EAAE,EAAE,SAAS,EAAE,SAAS,CAAC,MAAM,EAAE,OAAO,EAAE,QAAQ,CAAC,MAAM,EAAE;aAClE,CAAC;QAAA,CACF;KACD,CAAC,CAAC;AAAA,CACH;AAED,qEAAqE;AACrE,SAAS,sBAAsB,CAAC,QAAwB,EAAkB;IACzE,OAAO,UAAU,CAA2C;QAC3D,IAAI,EAAE,8BAA8B;QACpC,KAAK,EAAE,8BAA8B;QACrC,8EAA8E;QAC9E,wEAAwE;QACxE,2EAA2E;QAC3E,6EAA6E;QAC7E,+EAA+E;QAC/E,qEAAqE;QACrE,gFAA8E;QAC9E,8EAA8E;QAC9E,4BAA4B;QAC5B,WAAW,EACV,0OAA0O;QAC3O,aAAa,EAAE,8CAA8C;QAC7D,UAAU,EAAE,YAAY;QACxB,KAAK,CAAC,OAAO,CAAC,WAAW,EAAE,MAAoB,EAAE,MAAM,EAAE;YACxD,6EAA6E;YAC7E,gFAA8E;YAC9E,wCAAwC;YACxC,MAAM,QAAQ,GAAG,QAAQ,CAAC,aAAa,EAAE,CAAC,IAAI,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,UAAU,KAAK,MAAM,CAAC,UAAU,CAAC,CAAC;YAChG,IAAI,CAAC,QAAQ,EAAE,CAAC;gBACf,6EAA6E;gBAC7E,4CAA4C;gBAC5C,MAAM,IAAI,GAAG,QAAQ,CAAC,aAAa,EAAE,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,UAAU,CAAC,CAAC;gBACvE,MAAM,IAAI,KAAK,CACd,IAAI,CAAC,MAAM,KAAK,CAAC;oBAChB,CAAC,CAAC,mDAAmD;oBACrD,CAAC,CAAC,4BAA4B,MAAM,CAAC,UAAU,sBAAsB,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,CACxF,CAAC;YACH,CAAC;YAED,IAAI,CAAC;gBACJ,2EAA2E;gBAC3E,oEAAoE;gBACpE,MAAM,MAAM,GAAG,MAAM,QAAQ,CAAC,YAAY,CAAC,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,KAAc,EAAE,EAAE,MAAM,EAAE,CAAC,CAAC;gBACvG,MAAM,EAAE,IAAI,EAAE,SAAS,EAAE,GAAG,YAAY,CAAC,MAAM,CAAC,CAAC;gBACjD,OAAO;oBACN,GAAG,UAAU,CAAC,IAAI,CAAC;oBACnB,OAAO,EAAE,EAAE,UAAU,EAAE,MAAM,CAAC,UAAU,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,SAAS,EAAE;iBAC5E,CAAC;YACH,CAAC;YAAC,OAAO,KAAK,EAAE,CAAC;gBAChB,4EAA4E;gBAC5E,6EAA6E;gBAC7E,6EAA6E;gBAC7E,kEAAiE;gBACjE,MAAM,IAAI,GAAG,KAAK,YAAY,KAAK,IAAI,MAAM,IAAI,KAAK,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC;gBACxF,MAAM,OAAO,GAAG,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;gBACvE,MAAM,IAAI,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,GAAG,IAAI,KAAK,OAAO,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC;YACzD,CAAC;QAAA,CACD;KACD,CAAC,CAAC;AAAA,CACH;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,2BAA2B,CAAC,QAAwB,EAAoB;IACvF,IAAI,QAAQ,CAAC,aAAa,EAAE,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,EAAE,CAAC;IACrD,OAAO,CAAC,0BAA0B,CAAC,QAAQ,CAAC,EAAE,sBAAsB,CAAC,QAAQ,CAAC,CAAC,CAAC;AAAA,CAChF","sourcesContent":["/**\n * The two agent-facing canvas tools.\n *\n * Design: `docs/canvas-extensions-design.md` §11.5. Copilot names its own shape in\n * the SDK types — `list_canvas_capabilities` to discover, `invoke_canvas_action` to\n * invoke — and hoocode mirrors it, for its own reason as well as fidelity:\n * `AGENTS.md` budgets the prompt at ~4,140 tokens with ~2,710 of it tool schemas,\n * and every active tool's schema is re-sent on every request. One tool per open\n * action would make that surface grow with how many canvases are open. Two fixed\n * tools keep it flat.\n *\n * **There is deliberately no \"open a canvas\" tool.** Opening forks a process and\n * binds a listening socket; that is a person's decision, gated by workspace trust\n * (§5). The agent drives a surface a human has already opened. This also keeps the\n * injection surface flat: a poisoned issue title rendered into a canvas can at most\n * cause an action on an instance the person chose to open.\n *\n * These are optional tools, created only when canvas support is available and at\n * least one canvas is open — so a repository without canvases pays nothing.\n */\n\nimport { type Static, Type } from \"typebox\";\nimport type { CanvasRegistry } from \"../canvas/registry.js\";\nimport { defineTool, type ToolDefinition } from \"../extensions/types.js\";\n\n/** Tool name for capability discovery, matching Copilot's. */\nexport const LIST_CANVAS_CAPABILITIES_TOOL_NAME = \"list_canvas_capabilities\";\n/** Tool name for action invocation, matching Copilot's. */\nexport const INVOKE_CANVAS_ACTION_TOOL_NAME = \"invoke_canvas_action\";\n\n/**\n * Ceiling on a serialized action result, in characters.\n *\n * Whatever an action returns lands in the model's context window.\n * `pr-artifact-explorer` truncates its own payloads (`entries.slice(0, 200)`), but\n * nothing in the contract obliges a canvas to, so the host caps it too.\n */\nexport const CANVAS_RESULT_MAX_CHARS = 8_000;\n\nconst listParams = Type.Object({}, { additionalProperties: false });\n\nconst invokeParams = Type.Object(\n\t{\n\t\tinstanceId: Type.String({ description: \"From list_canvas_capabilities.\" }),\n\t\taction: Type.String({ description: \"Action name declared by that instance's canvas.\" }),\n\t\tinput: Type.Optional(Type.Unknown({ description: \"Action input, matching the action's declared schema.\" })),\n\t},\n\t{ additionalProperties: false },\n);\n\ntype InvokeParams = Static<typeof invokeParams>;\n\n/** What `list_canvas_capabilities` reports. */\nexport interface CanvasCapabilitiesDetails {\n\tinstances: number;\n\tactions: number;\n}\n\n/** What `invoke_canvas_action` reports. */\nexport interface CanvasInvokeDetails {\n\tinstanceId: string;\n\taction: string;\n\ttruncated: boolean;\n}\n\nfunction textResult(text: string) {\n\treturn { content: [{ type: \"text\" as const, text }] };\n}\n\n/** Serialize an action result, capped so a chatty canvas cannot flood the context. */\nfunction renderResult(value: unknown): { text: string; truncated: boolean } {\n\tconst serialized = value === undefined ? \"null\" : JSON.stringify(value, null, 1);\n\tif (serialized.length <= CANVAS_RESULT_MAX_CHARS) return { text: serialized, truncated: false };\n\treturn {\n\t\ttext: `${serialized.slice(0, CANVAS_RESULT_MAX_CHARS)}\\n… truncated at ${CANVAS_RESULT_MAX_CHARS} characters.`,\n\t\ttruncated: true,\n\t};\n}\n\n/**\n * Discovery: every open instance, its canvas, and the actions it declares with\n * their input schemas.\n *\n * Takes no parameters. A filter would add schema bytes on every request to save\n * bytes in a response the model reads once.\n */\nfunction createListCapabilitiesTool(registry: CanvasRegistry): ToolDefinition {\n\treturn defineTool<typeof listParams, CanvasCapabilitiesDetails>({\n\t\tname: LIST_CANVAS_CAPABILITIES_TOOL_NAME,\n\t\tlabel: LIST_CANVAS_CAPABILITIES_TOOL_NAME,\n\t\tdescription:\n\t\t\t\"List the open canvases and the actions each one accepts, with their input schemas. Call this before invoke_canvas_action to learn the instanceId and the action's schema.\",\n\t\tpromptSnippet: \"Discover open canvases and the actions they accept\",\n\t\tparameters: listParams,\n\t\tasync execute() {\n\t\t\tconst instances = registry.listInstances();\n\t\t\tconst bindings = registry.activeActions();\n\t\t\tif (instances.length === 0) {\n\t\t\t\treturn {\n\t\t\t\t\t...textResult(\"No canvas is open. A person opens a canvas; you can then drive it.\"),\n\t\t\t\t\tdetails: { instances: 0, actions: 0 },\n\t\t\t\t};\n\t\t\t}\n\t\t\tconst report = instances.map((instance) => ({\n\t\t\t\tinstanceId: instance.instanceId,\n\t\t\t\tcanvas: instance.canvasId,\n\t\t\t\textension: instance.extensionId,\n\t\t\t\ttitle: instance.title,\n\t\t\t\tstatus: instance.status,\n\t\t\t\tactions: bindings\n\t\t\t\t\t.filter((binding) => binding.instanceId === instance.instanceId)\n\t\t\t\t\t.map((binding) => binding.action),\n\t\t\t}));\n\t\t\treturn {\n\t\t\t\t...textResult(JSON.stringify(report, null, 1)),\n\t\t\t\tdetails: { instances: instances.length, actions: bindings.length },\n\t\t\t};\n\t\t},\n\t});\n}\n\n/** Invocation: run one declared action against one open instance. */\nfunction createInvokeActionTool(registry: CanvasRegistry): ToolDefinition {\n\treturn defineTool<typeof invokeParams, CanvasInvokeDetails>({\n\t\tname: INVOKE_CANVAS_ACTION_TOOL_NAME,\n\t\tlabel: INVOKE_CANVAS_ACTION_TOOL_NAME,\n\t\t// The description deliberately makes no safety claim. An earlier version said\n\t\t// actions \"cannot edit files or run commands\", which is false: a canvas\n\t\t// extension is arbitrary Node code running with the user's privileges, and\n\t\t// `pr-artifact-explorer` really does download artifacts to disk and call the\n\t\t// GitHub API. Those side effects never pass hoocode's permission gate, because\n\t\t// the gate sits in front of hoocode's own tools, not inside a forked\n\t\t// extension — the workspace-trust gate (canvas/trust.ts) is the control here,\n\t\t// not a sentence in a tool schema. Never tell the model a safety property the\n\t\t// runtime does not enforce.\n\t\tdescription:\n\t\t\t\"Invoke an action on an open canvas. Actions are implemented by the canvas extension itself: an action may change what the person is looking at and can have side effects of its own, so read the action's description before calling it.\",\n\t\tpromptSnippet: \"Act on an open canvas the user is looking at\",\n\t\tparameters: invokeParams,\n\t\tasync execute(_toolCallId, params: InvokeParams, signal) {\n\t\t\t// instanceId is a UUID and unique across every canvas, so the model does not\n\t\t\t// have to carry the extension and canvas ids too — the registry already knows\n\t\t\t// which instance a given id belongs to.\n\t\t\tconst instance = registry.listInstances().find((open) => open.instanceId === params.instanceId);\n\t\t\tif (!instance) {\n\t\t\t\t// Tools report failure by throwing here, as the built-ins do; the loop turns\n\t\t\t\t// a rejection into the model's tool result.\n\t\t\t\tconst open = registry.listInstances().map((other) => other.instanceId);\n\t\t\t\tthrow new Error(\n\t\t\t\t\topen.length === 0\n\t\t\t\t\t\t? \"No canvas is open, so there is nothing to act on.\"\n\t\t\t\t\t\t: `No open canvas instance \"${params.instanceId}\". Open instances: ${open.join(\", \")}.`,\n\t\t\t\t);\n\t\t\t}\n\n\t\t\ttry {\n\t\t\t\t// Honour the turn's abort signal: without this, aborting a turn leaves the\n\t\t\t\t// request running and its answer arriving for a turn nobody awaits.\n\t\t\t\tconst result = await registry.invokeAction(instance, params.action, params.input as never, { signal });\n\t\t\t\tconst { text, truncated } = renderResult(result);\n\t\t\t\treturn {\n\t\t\t\t\t...textResult(text),\n\t\t\t\t\tdetails: { instanceId: params.instanceId, action: params.action, truncated },\n\t\t\t\t};\n\t\t\t} catch (cause) {\n\t\t\t\t// Canvas handlers throw CanvasError with a machine-readable code, which the\n\t\t\t\t// runner preserves across the process boundary as CanvasCallError.code. Only\n\t\t\t\t// the message is rendered to the model, so fold the code into it rather than\n\t\t\t\t// letting the typed-error intent (§8) stop at the tool boundary.\n\t\t\t\tconst code = cause instanceof Error && \"code\" in cause ? String(cause.code) : undefined;\n\t\t\t\tconst message = cause instanceof Error ? cause.message : String(cause);\n\t\t\t\tthrow new Error(code ? `${code}: ${message}` : message);\n\t\t\t}\n\t\t},\n\t});\n}\n\n/**\n * The canvas tools, or none.\n *\n * Returns an empty array while nothing is open, so the two schemas are absent from\n * the prompt in the overwhelmingly common case of a repository with no canvases —\n * the same reason `registry.activeActions()` is empty until an instance exists\n * (§7). Callers re-derive this when the open set changes.\n */\nexport function createCanvasToolDefinitions(registry: CanvasRegistry): ToolDefinition[] {\n\tif (registry.listInstances().length === 0) return [];\n\treturn [createListCapabilitiesTool(registry), createInvokeActionTool(registry)];\n}\n"]}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/canvas` — the interactive surface for canvas extensions.
|
|
3
|
+
*
|
|
4
|
+
* Design: `docs/canvas-extensions-design.md` §11. Deliberately thin: every decision
|
|
5
|
+
* lives in `core/canvas/session.ts`, which is testable without a terminal, so this
|
|
6
|
+
* file only renders and supplies an `AbortSignal`.
|
|
7
|
+
*
|
|
8
|
+
* The signal is the point of the loader. `BorderedLoader` already gives Esc-to-cancel
|
|
9
|
+
* and exposes an `AbortSignal`, and `registry.open` accepts one — so a person's Esc
|
|
10
|
+
* reaches the abandon path (§11.6) and the extension is told to release the port it
|
|
11
|
+
* may already have bound, rather than the spinner merely disappearing.
|
|
12
|
+
*
|
|
13
|
+
* The two agent tools register on the first successful open and stay for the session:
|
|
14
|
+
* `registerTool` has no counterpart to remove a tool. So a session that never opens a
|
|
15
|
+
* canvas pays nothing for them, which is the case that matters (§11.5); after the
|
|
16
|
+
* first open they cost ~235 tokens and answer honestly when nothing is open.
|
|
17
|
+
*/
|
|
18
|
+
import type { ExtensionAPI } from "../../core/extensions/types.js";
|
|
19
|
+
export declare function setupCanvas(pi: ExtensionAPI): void;
|
|
20
|
+
//# sourceMappingURL=canvas.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"canvas.d.ts","sourceRoot":"","sources":["../../../src/extensions/core/canvas.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAWH,OAAO,KAAK,EAAE,YAAY,EAA2B,MAAM,gCAAgC,CAAC;AA6C5F,wBAAgB,WAAW,CAAC,EAAE,EAAE,YAAY,GAAG,IAAI,CAwJlD","sourcesContent":["/**\n * `/canvas` — the interactive surface for canvas extensions.\n *\n * Design: `docs/canvas-extensions-design.md` §11. Deliberately thin: every decision\n * lives in `core/canvas/session.ts`, which is testable without a terminal, so this\n * file only renders and supplies an `AbortSignal`.\n *\n * The signal is the point of the loader. `BorderedLoader` already gives Esc-to-cancel\n * and exposes an `AbortSignal`, and `registry.open` accepts one — so a person's Esc\n * reaches the abandon path (§11.6) and the extension is told to release the port it\n * may already have bound, rather than the spinner merely disappearing.\n *\n * The two agent tools register on the first successful open and stay for the session:\n * `registerTool` has no counterpart to remove a tool. So a session that never opens a\n * canvas pays nothing for them, which is the case that matters (§11.5); after the\n * first open they cost ~235 tokens and answer honestly when nothing is open.\n */\n\nimport { homedir } from \"node:os\";\nimport { getAgentDir } from \"../../config.js\";\nimport { CATEGORY_GLYPH } from \"../../core/brand.js\";\n\n/** Canvas extensions are extensions, so they wear the extension glyph. */\nconst GLYPH = CATEGORY_GLYPH.extensions;\n\nimport type { CanvasInstance } from \"../../core/canvas/registry.js\";\nimport { type CanvasOverview, CanvasSession, parseCanvasRef } from \"../../core/canvas/session.js\";\nimport type { ExtensionAPI, ExtensionCommandContext } from \"../../core/extensions/types.js\";\nimport { createCanvasToolDefinitions } from \"../../core/tools/canvas.js\";\nimport { BorderedLoader } from \"../../modes/interactive/components/bordered-loader.js\";\n\nconst SUBCOMMANDS = [\"list\", \"open\", \"close\"] as const;\n\n/** How an open attempt ended. `custom()` resolves with exactly one of these. */\ntype OpenOutcome =\n\t| { kind: \"opened\"; instance: CanvasInstance }\n\t| { kind: \"failed\"; message: string }\n\t| { kind: \"cancelled\" };\n\nfunction describeInstance(instance: CanvasInstance): string {\n\tconst title = instance.title ?? instance.canvasId;\n\treturn `${instance.instanceId} ${title}${instance.url ? ` ${instance.url}` : \"\"}`;\n}\n\nfunction renderOverview(overview: CanvasOverview): string {\n\tconst lines: string[] = [];\n\tif (!overview.availability.available) {\n\t\tlines.push(`Canvases are unavailable: ${overview.availability.reason}`, \"\");\n\t}\n\tif (overview.listings.length === 0) {\n\t\tlines.push(\"No canvas extensions found in .agents/extensions, .github/extensions, or ~/.copilot/extensions.\");\n\t\treturn lines.join(\"\\n\");\n\t}\n\tfor (const listing of overview.listings) {\n\t\tconst name = listing.canvasId ? `${listing.extensionId}:${listing.canvasId}` : listing.extensionId;\n\t\tconst label = listing.displayName ? ` ${listing.displayName}` : \"\";\n\t\tif (listing.withheld === \"untrusted-workspace\") {\n\t\t\tlines.push(`${GLYPH} ${name}${label} [withheld: untrusted workspace]`);\n\t\t\tcontinue;\n\t\t}\n\t\tlines.push(`${GLYPH} ${name}${label} (${listing.scope})`);\n\t\tfor (const instance of listing.open) lines.push(` open ${describeInstance(instance)}`);\n\t}\n\tif (overview.withheldCount > 0) {\n\t\tlines.push(\n\t\t\t\"\",\n\t\t\t`${overview.withheldCount} extension(s) came with this repository and are withheld. Run /plugin trust to allow this directory to run code it ships.`,\n\t\t);\n\t}\n\treturn lines.join(\"\\n\");\n}\n\nexport function setupCanvas(pi: ExtensionAPI): void {\n\tlet session: CanvasSession | undefined;\n\tlet toolsRegistered = false;\n\t/**\n\t * Points at the most recent command's UI.\n\t *\n\t * A canvas keeps talking after the command that opened it has returned — logs,\n\t * stray stdout, a leaked-port warning — so the callbacks cannot close over one\n\t * invocation's `ctx`.\n\t */\n\tlet notify: (message: string, type?: \"info\" | \"warning\" | \"error\") => void = () => {};\n\n\tconst ensureSession = (ctx: ExtensionCommandContext): CanvasSession => {\n\t\tnotify = (message, type) => ctx.ui.notify(message, type);\n\t\tsession ??= new CanvasSession({\n\t\t\tcwd: ctx.cwd,\n\t\t\thomeDir: homedir(),\n\t\t\tagentDir: getAgentDir(),\n\t\t\t// A canvas's own diagnostics are the user's business: a stray stdout line means\n\t\t\t// its author reached for console.log, and a possible leaked port is worth saying.\n\t\t\tonLog: (id, message) => notify(`[canvas ${id}] ${message}`, \"info\"),\n\t\t\tonStray: (id, line) => notify(`[canvas ${id}] non-protocol stdout (use session.log): ${line}`, \"warning\"),\n\t\t\tonDiagnostic: (id, message) => notify(`[canvas ${id}] ${message}`, \"warning\"),\n\t\t});\n\t\treturn session;\n\t};\n\n\tconst registerToolsOnce = (canvas: CanvasSession): void => {\n\t\tif (toolsRegistered) return;\n\t\tconst registry = canvas.registryOrUndefined();\n\t\tif (!registry) return;\n\t\ttoolsRegistered = true;\n\t\tfor (const definition of createCanvasToolDefinitions(registry)) pi.registerTool(definition);\n\t};\n\n\tpi.registerCommand(\"canvas\", {\n\t\tdescription:\n\t\t\t\"Work with canvas extensions. /canvas list | /canvas open <extension>[:<canvas>] | /canvas close <instanceId>\",\n\t\tgetArgumentCompletions: (prefix: string) =>\n\t\t\tSUBCOMMANDS.filter((name) => name.startsWith(prefix)).map((name) => ({ value: name, label: name })),\n\t\thandler: async (args: string, ctx: ExtensionCommandContext): Promise<void> => {\n\t\t\tconst trimmed = args.trim();\n\t\t\tconst canvas = ensureSession(ctx);\n\n\t\t\tif (trimmed.length === 0 || trimmed === \"list\") {\n\t\t\t\tctx.ui.notify(renderOverview(await canvas.list()), \"info\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tif (trimmed.startsWith(\"close\")) {\n\t\t\t\tconst instanceId = trimmed.slice(\"close\".length).trim();\n\t\t\t\tif (!instanceId) {\n\t\t\t\t\tctx.ui.notify(\"Usage: /canvas close <instanceId> (see /canvas list)\", \"warning\");\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tconst closed = await canvas.close(instanceId);\n\t\t\t\tif (!closed) ctx.ui.notify(`No open canvas instance \"${instanceId}\".`, \"warning\");\n\t\t\t\telse ctx.ui.notify(`Closed ${closed.canvasId} (${closed.instanceId}).`, \"info\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tif (!trimmed.startsWith(\"open\")) {\n\t\t\t\tctx.ui.notify(`Unknown subcommand. Use ${SUBCOMMANDS.join(\", \")}.`, \"warning\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst ref = parseCanvasRef(trimmed.slice(\"open\".length));\n\t\t\tif (!ref) {\n\t\t\t\tctx.ui.notify(\"Usage: /canvas open <extension>[:<canvas>] (see /canvas list)\", \"warning\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Opening forks a process and binds a port, so it can be slow and must be\n\t\t\t// interruptible. The loader's signal is what makes Esc mean something: it reaches\n\t\t\t// the registry's abandon path, which tells the extension to release a port it may\n\t\t\t// already have bound. Outside a terminal (--print, RPC) there is nothing to draw\n\t\t\t// and nothing to press, so the open simply runs.\n\t\t\tconst outcome = ctx.hasUI\n\t\t\t\t? await ctx.ui.custom<OpenOutcome | undefined>((tui, theme, _keybindings, done) => {\n\t\t\t\t\t\tconst loader = new BorderedLoader(tui, theme, `Opening ${ref.extensionId}…`);\n\t\t\t\t\t\tloader.onAbort = () => done({ kind: \"cancelled\" });\n\t\t\t\t\t\tvoid canvas\n\t\t\t\t\t\t\t.open(ref, { signal: loader.signal })\n\t\t\t\t\t\t\t.then((instance) => done({ kind: \"opened\", instance }))\n\t\t\t\t\t\t\t// Cancelling races: the signal rejects the pending call at the same moment\n\t\t\t\t\t\t\t// onAbort fires, and whichever lands first resolves `custom`. Deciding from\n\t\t\t\t\t\t\t// the signal rather than from who won means a cancel always reads as a\n\t\t\t\t\t\t\t// cancel instead of surfacing as an error.\n\t\t\t\t\t\t\t.catch((error: unknown) =>\n\t\t\t\t\t\t\t\tdone(\n\t\t\t\t\t\t\t\t\tloader.signal.aborted\n\t\t\t\t\t\t\t\t\t\t? { kind: \"cancelled\" }\n\t\t\t\t\t\t\t\t\t\t: { kind: \"failed\", message: error instanceof Error ? error.message : String(error) },\n\t\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t);\n\t\t\t\t\t\treturn loader;\n\t\t\t\t\t})\n\t\t\t\t: await canvas\n\t\t\t\t\t\t.open(ref)\n\t\t\t\t\t\t.then((instance): OpenOutcome => ({ kind: \"opened\", instance }))\n\t\t\t\t\t\t.catch(\n\t\t\t\t\t\t\t(error: unknown): OpenOutcome => ({\n\t\t\t\t\t\t\t\tkind: \"failed\",\n\t\t\t\t\t\t\t\tmessage: error instanceof Error ? error.message : String(error),\n\t\t\t\t\t\t\t}),\n\t\t\t\t\t\t);\n\n\t\t\t// `custom` can also settle on its own when the overlay is dismissed, without\n\t\t\t// our `done` ever running — an escape that closes the surface leaves no\n\t\t\t// outcome. Treat that as the cancel it is rather than reading `.kind` off\n\t\t\t// undefined and failing silently.\n\t\t\tif (!outcome || outcome.kind === \"cancelled\") {\n\t\t\t\t// KNOWN ISSUE: this confirmation does not render when the cancel came from the\n\t\t\t\t// loader's own escape handling, though every effect of cancelling is correct\n\t\t\t\t// and verified (the open rejects, no instance is registered, and the extension\n\t\t\t\t// is told to close the instance it never finished opening). Ruled out: the\n\t\t\t\t// continuation does run and `ctx.ui.notify` works here — the failure path\n\t\t\t\t// through the same lines renders its error, and the canvas's own diagnostic\n\t\t\t\t// arrives moments later through this very function. Deferring a tick did not\n\t\t\t\t// help either. Left as an unexplained cosmetic gap rather than papered over\n\t\t\t\t// with a sleep; the loader disappearing is itself the signal.\n\t\t\t\tctx.ui.notify(\"Canvas open cancelled.\", \"info\");\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (outcome.kind === \"failed\") {\n\t\t\t\tctx.ui.notify(outcome.message, \"error\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tregisterToolsOnce(canvas);\n\t\t\tctx.ui.notify(\n\t\t\t\t[\n\t\t\t\t\t`Opened ${outcome.instance.canvasId} (${outcome.instance.instanceId}).`,\n\t\t\t\t\toutcome.instance.url ? `Open in a browser: ${outcome.instance.url}` : \"\",\n\t\t\t\t\t\"The agent can now read and drive it; close it with /canvas close <instanceId>.\",\n\t\t\t\t]\n\t\t\t\t\t.filter((line) => line.length > 0)\n\t\t\t\t\t.join(\"\\n\"),\n\t\t\t\t\"info\",\n\t\t\t);\n\t\t},\n\t});\n\n\t// Teardown on shutdown (§6): a browser tab gives no close signal, so without this\n\t// every child and loopback port outlives the session. `session_shutdown` is where\n\t// loop.ts stops its scheduler, and it is synchronous, so the dispose is fired and\n\t// not awaited.\n\tpi.on(\"session_shutdown\", () => {\n\t\tconst closing = session;\n\t\tsession = undefined;\n\t\tvoid closing?.dispose();\n\t});\n}\n"]}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/canvas` — the interactive surface for canvas extensions.
|
|
3
|
+
*
|
|
4
|
+
* Design: `docs/canvas-extensions-design.md` §11. Deliberately thin: every decision
|
|
5
|
+
* lives in `core/canvas/session.ts`, which is testable without a terminal, so this
|
|
6
|
+
* file only renders and supplies an `AbortSignal`.
|
|
7
|
+
*
|
|
8
|
+
* The signal is the point of the loader. `BorderedLoader` already gives Esc-to-cancel
|
|
9
|
+
* and exposes an `AbortSignal`, and `registry.open` accepts one — so a person's Esc
|
|
10
|
+
* reaches the abandon path (§11.6) and the extension is told to release the port it
|
|
11
|
+
* may already have bound, rather than the spinner merely disappearing.
|
|
12
|
+
*
|
|
13
|
+
* The two agent tools register on the first successful open and stay for the session:
|
|
14
|
+
* `registerTool` has no counterpart to remove a tool. So a session that never opens a
|
|
15
|
+
* canvas pays nothing for them, which is the case that matters (§11.5); after the
|
|
16
|
+
* first open they cost ~235 tokens and answer honestly when nothing is open.
|
|
17
|
+
*/
|
|
18
|
+
import { homedir } from "node:os";
|
|
19
|
+
import { getAgentDir } from "../../config.js";
|
|
20
|
+
import { CATEGORY_GLYPH } from "../../core/brand.js";
|
|
21
|
+
/** Canvas extensions are extensions, so they wear the extension glyph. */
|
|
22
|
+
const GLYPH = CATEGORY_GLYPH.extensions;
|
|
23
|
+
import { CanvasSession, parseCanvasRef } from "../../core/canvas/session.js";
|
|
24
|
+
import { createCanvasToolDefinitions } from "../../core/tools/canvas.js";
|
|
25
|
+
import { BorderedLoader } from "../../modes/interactive/components/bordered-loader.js";
|
|
26
|
+
const SUBCOMMANDS = ["list", "open", "close"];
|
|
27
|
+
function describeInstance(instance) {
|
|
28
|
+
const title = instance.title ?? instance.canvasId;
|
|
29
|
+
return `${instance.instanceId} ${title}${instance.url ? ` ${instance.url}` : ""}`;
|
|
30
|
+
}
|
|
31
|
+
function renderOverview(overview) {
|
|
32
|
+
const lines = [];
|
|
33
|
+
if (!overview.availability.available) {
|
|
34
|
+
lines.push(`Canvases are unavailable: ${overview.availability.reason}`, "");
|
|
35
|
+
}
|
|
36
|
+
if (overview.listings.length === 0) {
|
|
37
|
+
lines.push("No canvas extensions found in .agents/extensions, .github/extensions, or ~/.copilot/extensions.");
|
|
38
|
+
return lines.join("\n");
|
|
39
|
+
}
|
|
40
|
+
for (const listing of overview.listings) {
|
|
41
|
+
const name = listing.canvasId ? `${listing.extensionId}:${listing.canvasId}` : listing.extensionId;
|
|
42
|
+
const label = listing.displayName ? ` ${listing.displayName}` : "";
|
|
43
|
+
if (listing.withheld === "untrusted-workspace") {
|
|
44
|
+
lines.push(`${GLYPH} ${name}${label} [withheld: untrusted workspace]`);
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
lines.push(`${GLYPH} ${name}${label} (${listing.scope})`);
|
|
48
|
+
for (const instance of listing.open)
|
|
49
|
+
lines.push(` open ${describeInstance(instance)}`);
|
|
50
|
+
}
|
|
51
|
+
if (overview.withheldCount > 0) {
|
|
52
|
+
lines.push("", `${overview.withheldCount} extension(s) came with this repository and are withheld. Run /plugin trust to allow this directory to run code it ships.`);
|
|
53
|
+
}
|
|
54
|
+
return lines.join("\n");
|
|
55
|
+
}
|
|
56
|
+
export function setupCanvas(pi) {
|
|
57
|
+
let session;
|
|
58
|
+
let toolsRegistered = false;
|
|
59
|
+
/**
|
|
60
|
+
* Points at the most recent command's UI.
|
|
61
|
+
*
|
|
62
|
+
* A canvas keeps talking after the command that opened it has returned — logs,
|
|
63
|
+
* stray stdout, a leaked-port warning — so the callbacks cannot close over one
|
|
64
|
+
* invocation's `ctx`.
|
|
65
|
+
*/
|
|
66
|
+
let notify = () => { };
|
|
67
|
+
const ensureSession = (ctx) => {
|
|
68
|
+
notify = (message, type) => ctx.ui.notify(message, type);
|
|
69
|
+
session ??= new CanvasSession({
|
|
70
|
+
cwd: ctx.cwd,
|
|
71
|
+
homeDir: homedir(),
|
|
72
|
+
agentDir: getAgentDir(),
|
|
73
|
+
// A canvas's own diagnostics are the user's business: a stray stdout line means
|
|
74
|
+
// its author reached for console.log, and a possible leaked port is worth saying.
|
|
75
|
+
onLog: (id, message) => notify(`[canvas ${id}] ${message}`, "info"),
|
|
76
|
+
onStray: (id, line) => notify(`[canvas ${id}] non-protocol stdout (use session.log): ${line}`, "warning"),
|
|
77
|
+
onDiagnostic: (id, message) => notify(`[canvas ${id}] ${message}`, "warning"),
|
|
78
|
+
});
|
|
79
|
+
return session;
|
|
80
|
+
};
|
|
81
|
+
const registerToolsOnce = (canvas) => {
|
|
82
|
+
if (toolsRegistered)
|
|
83
|
+
return;
|
|
84
|
+
const registry = canvas.registryOrUndefined();
|
|
85
|
+
if (!registry)
|
|
86
|
+
return;
|
|
87
|
+
toolsRegistered = true;
|
|
88
|
+
for (const definition of createCanvasToolDefinitions(registry))
|
|
89
|
+
pi.registerTool(definition);
|
|
90
|
+
};
|
|
91
|
+
pi.registerCommand("canvas", {
|
|
92
|
+
description: "Work with canvas extensions. /canvas list | /canvas open <extension>[:<canvas>] | /canvas close <instanceId>",
|
|
93
|
+
getArgumentCompletions: (prefix) => SUBCOMMANDS.filter((name) => name.startsWith(prefix)).map((name) => ({ value: name, label: name })),
|
|
94
|
+
handler: async (args, ctx) => {
|
|
95
|
+
const trimmed = args.trim();
|
|
96
|
+
const canvas = ensureSession(ctx);
|
|
97
|
+
if (trimmed.length === 0 || trimmed === "list") {
|
|
98
|
+
ctx.ui.notify(renderOverview(await canvas.list()), "info");
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (trimmed.startsWith("close")) {
|
|
102
|
+
const instanceId = trimmed.slice("close".length).trim();
|
|
103
|
+
if (!instanceId) {
|
|
104
|
+
ctx.ui.notify("Usage: /canvas close <instanceId> (see /canvas list)", "warning");
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const closed = await canvas.close(instanceId);
|
|
108
|
+
if (!closed)
|
|
109
|
+
ctx.ui.notify(`No open canvas instance "${instanceId}".`, "warning");
|
|
110
|
+
else
|
|
111
|
+
ctx.ui.notify(`Closed ${closed.canvasId} (${closed.instanceId}).`, "info");
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (!trimmed.startsWith("open")) {
|
|
115
|
+
ctx.ui.notify(`Unknown subcommand. Use ${SUBCOMMANDS.join(", ")}.`, "warning");
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const ref = parseCanvasRef(trimmed.slice("open".length));
|
|
119
|
+
if (!ref) {
|
|
120
|
+
ctx.ui.notify("Usage: /canvas open <extension>[:<canvas>] (see /canvas list)", "warning");
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
// Opening forks a process and binds a port, so it can be slow and must be
|
|
124
|
+
// interruptible. The loader's signal is what makes Esc mean something: it reaches
|
|
125
|
+
// the registry's abandon path, which tells the extension to release a port it may
|
|
126
|
+
// already have bound. Outside a terminal (--print, RPC) there is nothing to draw
|
|
127
|
+
// and nothing to press, so the open simply runs.
|
|
128
|
+
const outcome = ctx.hasUI
|
|
129
|
+
? await ctx.ui.custom((tui, theme, _keybindings, done) => {
|
|
130
|
+
const loader = new BorderedLoader(tui, theme, `Opening ${ref.extensionId}…`);
|
|
131
|
+
loader.onAbort = () => done({ kind: "cancelled" });
|
|
132
|
+
void canvas
|
|
133
|
+
.open(ref, { signal: loader.signal })
|
|
134
|
+
.then((instance) => done({ kind: "opened", instance }))
|
|
135
|
+
// Cancelling races: the signal rejects the pending call at the same moment
|
|
136
|
+
// onAbort fires, and whichever lands first resolves `custom`. Deciding from
|
|
137
|
+
// the signal rather than from who won means a cancel always reads as a
|
|
138
|
+
// cancel instead of surfacing as an error.
|
|
139
|
+
.catch((error) => done(loader.signal.aborted
|
|
140
|
+
? { kind: "cancelled" }
|
|
141
|
+
: { kind: "failed", message: error instanceof Error ? error.message : String(error) }));
|
|
142
|
+
return loader;
|
|
143
|
+
})
|
|
144
|
+
: await canvas
|
|
145
|
+
.open(ref)
|
|
146
|
+
.then((instance) => ({ kind: "opened", instance }))
|
|
147
|
+
.catch((error) => ({
|
|
148
|
+
kind: "failed",
|
|
149
|
+
message: error instanceof Error ? error.message : String(error),
|
|
150
|
+
}));
|
|
151
|
+
// `custom` can also settle on its own when the overlay is dismissed, without
|
|
152
|
+
// our `done` ever running — an escape that closes the surface leaves no
|
|
153
|
+
// outcome. Treat that as the cancel it is rather than reading `.kind` off
|
|
154
|
+
// undefined and failing silently.
|
|
155
|
+
if (!outcome || outcome.kind === "cancelled") {
|
|
156
|
+
// KNOWN ISSUE: this confirmation does not render when the cancel came from the
|
|
157
|
+
// loader's own escape handling, though every effect of cancelling is correct
|
|
158
|
+
// and verified (the open rejects, no instance is registered, and the extension
|
|
159
|
+
// is told to close the instance it never finished opening). Ruled out: the
|
|
160
|
+
// continuation does run and `ctx.ui.notify` works here — the failure path
|
|
161
|
+
// through the same lines renders its error, and the canvas's own diagnostic
|
|
162
|
+
// arrives moments later through this very function. Deferring a tick did not
|
|
163
|
+
// help either. Left as an unexplained cosmetic gap rather than papered over
|
|
164
|
+
// with a sleep; the loader disappearing is itself the signal.
|
|
165
|
+
ctx.ui.notify("Canvas open cancelled.", "info");
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
if (outcome.kind === "failed") {
|
|
169
|
+
ctx.ui.notify(outcome.message, "error");
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
registerToolsOnce(canvas);
|
|
173
|
+
ctx.ui.notify([
|
|
174
|
+
`Opened ${outcome.instance.canvasId} (${outcome.instance.instanceId}).`,
|
|
175
|
+
outcome.instance.url ? `Open in a browser: ${outcome.instance.url}` : "",
|
|
176
|
+
"The agent can now read and drive it; close it with /canvas close <instanceId>.",
|
|
177
|
+
]
|
|
178
|
+
.filter((line) => line.length > 0)
|
|
179
|
+
.join("\n"), "info");
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
// Teardown on shutdown (§6): a browser tab gives no close signal, so without this
|
|
183
|
+
// every child and loopback port outlives the session. `session_shutdown` is where
|
|
184
|
+
// loop.ts stops its scheduler, and it is synchronous, so the dispose is fired and
|
|
185
|
+
// not awaited.
|
|
186
|
+
pi.on("session_shutdown", () => {
|
|
187
|
+
const closing = session;
|
|
188
|
+
session = undefined;
|
|
189
|
+
void closing?.dispose();
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
//# sourceMappingURL=canvas.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"canvas.js","sourceRoot":"","sources":["../../../src/extensions/core/canvas.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * `/canvas` — the interactive surface for canvas extensions.\n *\n * Design: `docs/canvas-extensions-design.md` §11. Deliberately thin: every decision\n * lives in `core/canvas/session.ts`, which is testable without a terminal, so this\n * file only renders and supplies an `AbortSignal`.\n *\n * The signal is the point of the loader. `BorderedLoader` already gives Esc-to-cancel\n * and exposes an `AbortSignal`, and `registry.open` accepts one — so a person's Esc\n * reaches the abandon path (§11.6) and the extension is told to release the port it\n * may already have bound, rather than the spinner merely disappearing.\n *\n * The two agent tools register on the first successful open and stay for the session:\n * `registerTool` has no counterpart to remove a tool. So a session that never opens a\n * canvas pays nothing for them, which is the case that matters (§11.5); after the\n * first open they cost ~235 tokens and answer honestly when nothing is open.\n */\n\nimport { homedir } from \"node:os\";\nimport { getAgentDir } from \"../../config.js\";\nimport { CATEGORY_GLYPH } from \"../../core/brand.js\";\n\n/** Canvas extensions are extensions, so they wear the extension glyph. */\nconst GLYPH = CATEGORY_GLYPH.extensions;\n\nimport type { CanvasInstance } from \"../../core/canvas/registry.js\";\nimport { type CanvasOverview, CanvasSession, parseCanvasRef } from \"../../core/canvas/session.js\";\nimport type { ExtensionAPI, ExtensionCommandContext } from \"../../core/extensions/types.js\";\nimport { createCanvasToolDefinitions } from \"../../core/tools/canvas.js\";\nimport { BorderedLoader } from \"../../modes/interactive/components/bordered-loader.js\";\n\nconst SUBCOMMANDS = [\"list\", \"open\", \"close\"] as const;\n\n/** How an open attempt ended. `custom()` resolves with exactly one of these. */\ntype OpenOutcome =\n\t| { kind: \"opened\"; instance: CanvasInstance }\n\t| { kind: \"failed\"; message: string }\n\t| { kind: \"cancelled\" };\n\nfunction describeInstance(instance: CanvasInstance): string {\n\tconst title = instance.title ?? instance.canvasId;\n\treturn `${instance.instanceId} ${title}${instance.url ? ` ${instance.url}` : \"\"}`;\n}\n\nfunction renderOverview(overview: CanvasOverview): string {\n\tconst lines: string[] = [];\n\tif (!overview.availability.available) {\n\t\tlines.push(`Canvases are unavailable: ${overview.availability.reason}`, \"\");\n\t}\n\tif (overview.listings.length === 0) {\n\t\tlines.push(\"No canvas extensions found in .agents/extensions, .github/extensions, or ~/.copilot/extensions.\");\n\t\treturn lines.join(\"\\n\");\n\t}\n\tfor (const listing of overview.listings) {\n\t\tconst name = listing.canvasId ? `${listing.extensionId}:${listing.canvasId}` : listing.extensionId;\n\t\tconst label = listing.displayName ? ` ${listing.displayName}` : \"\";\n\t\tif (listing.withheld === \"untrusted-workspace\") {\n\t\t\tlines.push(`${GLYPH} ${name}${label} [withheld: untrusted workspace]`);\n\t\t\tcontinue;\n\t\t}\n\t\tlines.push(`${GLYPH} ${name}${label} (${listing.scope})`);\n\t\tfor (const instance of listing.open) lines.push(` open ${describeInstance(instance)}`);\n\t}\n\tif (overview.withheldCount > 0) {\n\t\tlines.push(\n\t\t\t\"\",\n\t\t\t`${overview.withheldCount} extension(s) came with this repository and are withheld. Run /plugin trust to allow this directory to run code it ships.`,\n\t\t);\n\t}\n\treturn lines.join(\"\\n\");\n}\n\nexport function setupCanvas(pi: ExtensionAPI): void {\n\tlet session: CanvasSession | undefined;\n\tlet toolsRegistered = false;\n\t/**\n\t * Points at the most recent command's UI.\n\t *\n\t * A canvas keeps talking after the command that opened it has returned — logs,\n\t * stray stdout, a leaked-port warning — so the callbacks cannot close over one\n\t * invocation's `ctx`.\n\t */\n\tlet notify: (message: string, type?: \"info\" | \"warning\" | \"error\") => void = () => {};\n\n\tconst ensureSession = (ctx: ExtensionCommandContext): CanvasSession => {\n\t\tnotify = (message, type) => ctx.ui.notify(message, type);\n\t\tsession ??= new CanvasSession({\n\t\t\tcwd: ctx.cwd,\n\t\t\thomeDir: homedir(),\n\t\t\tagentDir: getAgentDir(),\n\t\t\t// A canvas's own diagnostics are the user's business: a stray stdout line means\n\t\t\t// its author reached for console.log, and a possible leaked port is worth saying.\n\t\t\tonLog: (id, message) => notify(`[canvas ${id}] ${message}`, \"info\"),\n\t\t\tonStray: (id, line) => notify(`[canvas ${id}] non-protocol stdout (use session.log): ${line}`, \"warning\"),\n\t\t\tonDiagnostic: (id, message) => notify(`[canvas ${id}] ${message}`, \"warning\"),\n\t\t});\n\t\treturn session;\n\t};\n\n\tconst registerToolsOnce = (canvas: CanvasSession): void => {\n\t\tif (toolsRegistered) return;\n\t\tconst registry = canvas.registryOrUndefined();\n\t\tif (!registry) return;\n\t\ttoolsRegistered = true;\n\t\tfor (const definition of createCanvasToolDefinitions(registry)) pi.registerTool(definition);\n\t};\n\n\tpi.registerCommand(\"canvas\", {\n\t\tdescription:\n\t\t\t\"Work with canvas extensions. /canvas list | /canvas open <extension>[:<canvas>] | /canvas close <instanceId>\",\n\t\tgetArgumentCompletions: (prefix: string) =>\n\t\t\tSUBCOMMANDS.filter((name) => name.startsWith(prefix)).map((name) => ({ value: name, label: name })),\n\t\thandler: async (args: string, ctx: ExtensionCommandContext): Promise<void> => {\n\t\t\tconst trimmed = args.trim();\n\t\t\tconst canvas = ensureSession(ctx);\n\n\t\t\tif (trimmed.length === 0 || trimmed === \"list\") {\n\t\t\t\tctx.ui.notify(renderOverview(await canvas.list()), \"info\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tif (trimmed.startsWith(\"close\")) {\n\t\t\t\tconst instanceId = trimmed.slice(\"close\".length).trim();\n\t\t\t\tif (!instanceId) {\n\t\t\t\t\tctx.ui.notify(\"Usage: /canvas close <instanceId> (see /canvas list)\", \"warning\");\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tconst closed = await canvas.close(instanceId);\n\t\t\t\tif (!closed) ctx.ui.notify(`No open canvas instance \"${instanceId}\".`, \"warning\");\n\t\t\t\telse ctx.ui.notify(`Closed ${closed.canvasId} (${closed.instanceId}).`, \"info\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tif (!trimmed.startsWith(\"open\")) {\n\t\t\t\tctx.ui.notify(`Unknown subcommand. Use ${SUBCOMMANDS.join(\", \")}.`, \"warning\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst ref = parseCanvasRef(trimmed.slice(\"open\".length));\n\t\t\tif (!ref) {\n\t\t\t\tctx.ui.notify(\"Usage: /canvas open <extension>[:<canvas>] (see /canvas list)\", \"warning\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Opening forks a process and binds a port, so it can be slow and must be\n\t\t\t// interruptible. The loader's signal is what makes Esc mean something: it reaches\n\t\t\t// the registry's abandon path, which tells the extension to release a port it may\n\t\t\t// already have bound. Outside a terminal (--print, RPC) there is nothing to draw\n\t\t\t// and nothing to press, so the open simply runs.\n\t\t\tconst outcome = ctx.hasUI\n\t\t\t\t? await ctx.ui.custom<OpenOutcome | undefined>((tui, theme, _keybindings, done) => {\n\t\t\t\t\t\tconst loader = new BorderedLoader(tui, theme, `Opening ${ref.extensionId}…`);\n\t\t\t\t\t\tloader.onAbort = () => done({ kind: \"cancelled\" });\n\t\t\t\t\t\tvoid canvas\n\t\t\t\t\t\t\t.open(ref, { signal: loader.signal })\n\t\t\t\t\t\t\t.then((instance) => done({ kind: \"opened\", instance }))\n\t\t\t\t\t\t\t// Cancelling races: the signal rejects the pending call at the same moment\n\t\t\t\t\t\t\t// onAbort fires, and whichever lands first resolves `custom`. Deciding from\n\t\t\t\t\t\t\t// the signal rather than from who won means a cancel always reads as a\n\t\t\t\t\t\t\t// cancel instead of surfacing as an error.\n\t\t\t\t\t\t\t.catch((error: unknown) =>\n\t\t\t\t\t\t\t\tdone(\n\t\t\t\t\t\t\t\t\tloader.signal.aborted\n\t\t\t\t\t\t\t\t\t\t? { kind: \"cancelled\" }\n\t\t\t\t\t\t\t\t\t\t: { kind: \"failed\", message: error instanceof Error ? error.message : String(error) },\n\t\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t);\n\t\t\t\t\t\treturn loader;\n\t\t\t\t\t})\n\t\t\t\t: await canvas\n\t\t\t\t\t\t.open(ref)\n\t\t\t\t\t\t.then((instance): OpenOutcome => ({ kind: \"opened\", instance }))\n\t\t\t\t\t\t.catch(\n\t\t\t\t\t\t\t(error: unknown): OpenOutcome => ({\n\t\t\t\t\t\t\t\tkind: \"failed\",\n\t\t\t\t\t\t\t\tmessage: error instanceof Error ? error.message : String(error),\n\t\t\t\t\t\t\t}),\n\t\t\t\t\t\t);\n\n\t\t\t// `custom` can also settle on its own when the overlay is dismissed, without\n\t\t\t// our `done` ever running — an escape that closes the surface leaves no\n\t\t\t// outcome. Treat that as the cancel it is rather than reading `.kind` off\n\t\t\t// undefined and failing silently.\n\t\t\tif (!outcome || outcome.kind === \"cancelled\") {\n\t\t\t\t// KNOWN ISSUE: this confirmation does not render when the cancel came from the\n\t\t\t\t// loader's own escape handling, though every effect of cancelling is correct\n\t\t\t\t// and verified (the open rejects, no instance is registered, and the extension\n\t\t\t\t// is told to close the instance it never finished opening). Ruled out: the\n\t\t\t\t// continuation does run and `ctx.ui.notify` works here — the failure path\n\t\t\t\t// through the same lines renders its error, and the canvas's own diagnostic\n\t\t\t\t// arrives moments later through this very function. Deferring a tick did not\n\t\t\t\t// help either. Left as an unexplained cosmetic gap rather than papered over\n\t\t\t\t// with a sleep; the loader disappearing is itself the signal.\n\t\t\t\tctx.ui.notify(\"Canvas open cancelled.\", \"info\");\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (outcome.kind === \"failed\") {\n\t\t\t\tctx.ui.notify(outcome.message, \"error\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tregisterToolsOnce(canvas);\n\t\t\tctx.ui.notify(\n\t\t\t\t[\n\t\t\t\t\t`Opened ${outcome.instance.canvasId} (${outcome.instance.instanceId}).`,\n\t\t\t\t\toutcome.instance.url ? `Open in a browser: ${outcome.instance.url}` : \"\",\n\t\t\t\t\t\"The agent can now read and drive it; close it with /canvas close <instanceId>.\",\n\t\t\t\t]\n\t\t\t\t\t.filter((line) => line.length > 0)\n\t\t\t\t\t.join(\"\\n\"),\n\t\t\t\t\"info\",\n\t\t\t);\n\t\t},\n\t});\n\n\t// Teardown on shutdown (§6): a browser tab gives no close signal, so without this\n\t// every child and loopback port outlives the session. `session_shutdown` is where\n\t// loop.ts stops its scheduler, and it is synchronous, so the dispose is fired and\n\t// not awaited.\n\tpi.on(\"session_shutdown\", () => {\n\t\tconst closing = session;\n\t\tsession = undefined;\n\t\tvoid closing?.dispose();\n\t});\n}\n"]}
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* - thinking-escalation.ts — raise thinking after tool errors, then restore
|
|
12
12
|
* - loop.ts — /loop cron scheduling + autonomous continuation
|
|
13
13
|
* - marketplace.ts — /plugin marketplace + install/remove
|
|
14
|
+
* - canvas.ts — /canvas list/open/close for canvas extensions
|
|
14
15
|
* - prompt-reactive/ — runtime plugin-reuse nudge (reactive to tool/turn cues)
|
|
15
16
|
* - config.ts — hoo-config.json types, I/O, and merge rules
|
|
16
17
|
*
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"hoo-core.d.ts","sourceRoot":"","sources":["../../../src/extensions/core/hoo-core.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"hoo-core.d.ts","sourceRoot":"","sources":["../../../src/extensions/core/hoo-core.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,gCAAgC,CAAC;AAcnE,iBAAS,OAAO,CAAC,EAAE,EAAE,YAAY,GAAG,IAAI,CAavC;;;;;;;AAMD,eAAe,OAAO,CAAC","sourcesContent":["/**\n * hoo-core — HooCode built-in core extension (composition root).\n *\n * Each concern lives in its own module in this directory:\n * - permission-gate.ts — prompts before bash/write/edit; hard tool/command policy\n * - mcp-loader.ts — discovers MCP server configs, connects, registers tools\n * - modes.ts — active mode resolution, mode prompts, /mode /plan /approve\n * - cost.ts — /cost session token + cost totals\n * - scaffold.ts — /new-skill /new-agent /new-command\n * - ask-options.ts — the ask_options tool (inline decision pane)\n * - thinking-escalation.ts — raise thinking after tool errors, then restore\n * - loop.ts — /loop cron scheduling + autonomous continuation\n * - marketplace.ts — /plugin marketplace + install/remove\n * - canvas.ts — /canvas list/open/close for canvas extensions\n * - prompt-reactive/ — runtime plugin-reuse nudge (reactive to tool/turn cues)\n * - config.ts — hoo-config.json types, I/O, and merge rules\n *\n * `bin/hoocode.js` loads this module's default export as the built-in\n * extension factory.\n */\n\nimport type { ExtensionAPI } from \"../../core/extensions/types.js\";\nimport { setupAskOptions } from \"./ask-options.js\";\nimport { setupCanvas } from \"./canvas.js\";\nimport { setupCost } from \"./cost.js\";\nimport { setupLearn } from \"./learn.js\";\nimport { setupLoop } from \"./loop.js\";\nimport { setupMarketplace } from \"./marketplace.js\";\nimport { setupMcpLoader } from \"./mcp-loader.js\";\nimport { setupMode } from \"./modes.js\";\nimport { setupPermissionGate } from \"./permission-gate.js\";\nimport { setupPromptReactiveNudges } from \"./prompt-reactive/nudges.js\";\nimport { setupScaffold } from \"./scaffold.js\";\nimport { setupThinkingEscalation } from \"./thinking-escalation.js\";\n\nfunction hooCore(pi: ExtensionAPI): void {\n\tsetupPermissionGate(pi);\n\tsetupMcpLoader(pi);\n\tsetupMode(pi);\n\tsetupCost(pi);\n\tsetupScaffold(pi);\n\tsetupAskOptions(pi);\n\tsetupThinkingEscalation(pi);\n\tsetupLoop(pi);\n\tsetupMarketplace(pi);\n\tsetupCanvas(pi);\n\tsetupPromptReactiveNudges(pi);\n\tsetupLearn(pi);\n}\n\nhooCore.displayName = \"hoo-core\";\n// Built-in plumbing, not a user-installed extension: it is always present, so\n// listing it in the startup resource summary is noise, not information.\nhooCore.internal = true;\nexport default hooCore;\n"]}
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* - thinking-escalation.ts — raise thinking after tool errors, then restore
|
|
12
12
|
* - loop.ts — /loop cron scheduling + autonomous continuation
|
|
13
13
|
* - marketplace.ts — /plugin marketplace + install/remove
|
|
14
|
+
* - canvas.ts — /canvas list/open/close for canvas extensions
|
|
14
15
|
* - prompt-reactive/ — runtime plugin-reuse nudge (reactive to tool/turn cues)
|
|
15
16
|
* - config.ts — hoo-config.json types, I/O, and merge rules
|
|
16
17
|
*
|
|
@@ -18,6 +19,7 @@
|
|
|
18
19
|
* extension factory.
|
|
19
20
|
*/
|
|
20
21
|
import { setupAskOptions } from "./ask-options.js";
|
|
22
|
+
import { setupCanvas } from "./canvas.js";
|
|
21
23
|
import { setupCost } from "./cost.js";
|
|
22
24
|
import { setupLearn } from "./learn.js";
|
|
23
25
|
import { setupLoop } from "./loop.js";
|
|
@@ -38,6 +40,7 @@ function hooCore(pi) {
|
|
|
38
40
|
setupThinkingEscalation(pi);
|
|
39
41
|
setupLoop(pi);
|
|
40
42
|
setupMarketplace(pi);
|
|
43
|
+
setupCanvas(pi);
|
|
41
44
|
setupPromptReactiveNudges(pi);
|
|
42
45
|
setupLearn(pi);
|
|
43
46
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"hoo-core.js","sourceRoot":"","sources":["../../../src/extensions/core/hoo-core.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"hoo-core.js","sourceRoot":"","sources":["../../../src/extensions/core/hoo-core.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAGH,OAAO,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAC;AACnD,OAAO,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAC1C,OAAO,EAAE,SAAS,EAAE,MAAM,WAAW,CAAC;AACtC,OAAO,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AACxC,OAAO,EAAE,SAAS,EAAE,MAAM,WAAW,CAAC;AACtC,OAAO,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AACpD,OAAO,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AACjD,OAAO,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AACvC,OAAO,EAAE,mBAAmB,EAAE,MAAM,sBAAsB,CAAC;AAC3D,OAAO,EAAE,yBAAyB,EAAE,MAAM,6BAA6B,CAAC;AACxE,OAAO,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAC9C,OAAO,EAAE,uBAAuB,EAAE,MAAM,0BAA0B,CAAC;AAEnE,SAAS,OAAO,CAAC,EAAgB,EAAQ;IACxC,mBAAmB,CAAC,EAAE,CAAC,CAAC;IACxB,cAAc,CAAC,EAAE,CAAC,CAAC;IACnB,SAAS,CAAC,EAAE,CAAC,CAAC;IACd,SAAS,CAAC,EAAE,CAAC,CAAC;IACd,aAAa,CAAC,EAAE,CAAC,CAAC;IAClB,eAAe,CAAC,EAAE,CAAC,CAAC;IACpB,uBAAuB,CAAC,EAAE,CAAC,CAAC;IAC5B,SAAS,CAAC,EAAE,CAAC,CAAC;IACd,gBAAgB,CAAC,EAAE,CAAC,CAAC;IACrB,WAAW,CAAC,EAAE,CAAC,CAAC;IAChB,yBAAyB,CAAC,EAAE,CAAC,CAAC;IAC9B,UAAU,CAAC,EAAE,CAAC,CAAC;AAAA,CACf;AAED,OAAO,CAAC,WAAW,GAAG,UAAU,CAAC;AACjC,8EAA8E;AAC9E,wEAAwE;AACxE,OAAO,CAAC,QAAQ,GAAG,IAAI,CAAC;AACxB,eAAe,OAAO,CAAC","sourcesContent":["/**\n * hoo-core — HooCode built-in core extension (composition root).\n *\n * Each concern lives in its own module in this directory:\n * - permission-gate.ts — prompts before bash/write/edit; hard tool/command policy\n * - mcp-loader.ts — discovers MCP server configs, connects, registers tools\n * - modes.ts — active mode resolution, mode prompts, /mode /plan /approve\n * - cost.ts — /cost session token + cost totals\n * - scaffold.ts — /new-skill /new-agent /new-command\n * - ask-options.ts — the ask_options tool (inline decision pane)\n * - thinking-escalation.ts — raise thinking after tool errors, then restore\n * - loop.ts — /loop cron scheduling + autonomous continuation\n * - marketplace.ts — /plugin marketplace + install/remove\n * - canvas.ts — /canvas list/open/close for canvas extensions\n * - prompt-reactive/ — runtime plugin-reuse nudge (reactive to tool/turn cues)\n * - config.ts — hoo-config.json types, I/O, and merge rules\n *\n * `bin/hoocode.js` loads this module's default export as the built-in\n * extension factory.\n */\n\nimport type { ExtensionAPI } from \"../../core/extensions/types.js\";\nimport { setupAskOptions } from \"./ask-options.js\";\nimport { setupCanvas } from \"./canvas.js\";\nimport { setupCost } from \"./cost.js\";\nimport { setupLearn } from \"./learn.js\";\nimport { setupLoop } from \"./loop.js\";\nimport { setupMarketplace } from \"./marketplace.js\";\nimport { setupMcpLoader } from \"./mcp-loader.js\";\nimport { setupMode } from \"./modes.js\";\nimport { setupPermissionGate } from \"./permission-gate.js\";\nimport { setupPromptReactiveNudges } from \"./prompt-reactive/nudges.js\";\nimport { setupScaffold } from \"./scaffold.js\";\nimport { setupThinkingEscalation } from \"./thinking-escalation.js\";\n\nfunction hooCore(pi: ExtensionAPI): void {\n\tsetupPermissionGate(pi);\n\tsetupMcpLoader(pi);\n\tsetupMode(pi);\n\tsetupCost(pi);\n\tsetupScaffold(pi);\n\tsetupAskOptions(pi);\n\tsetupThinkingEscalation(pi);\n\tsetupLoop(pi);\n\tsetupMarketplace(pi);\n\tsetupCanvas(pi);\n\tsetupPromptReactiveNudges(pi);\n\tsetupLearn(pi);\n}\n\nhooCore.displayName = \"hoo-core\";\n// Built-in plumbing, not a user-installed extension: it is always present, so\n// listing it in the startup resource summary is noise, not information.\nhooCore.internal = true;\nexport default hooCore;\n"]}
|
package/dist/utils/paths.d.ts
CHANGED
|
@@ -5,6 +5,15 @@
|
|
|
5
5
|
* entries.
|
|
6
6
|
*/
|
|
7
7
|
export declare function canonicalizePath(path: string): string;
|
|
8
|
+
/**
|
|
9
|
+
* Whether `target` is `root` itself or sits inside it.
|
|
10
|
+
*
|
|
11
|
+
* Segment-aware, so `/a/bc` is not treated as being inside `/a/b`. Four private
|
|
12
|
+
* copies of this predicate already exist (`core/extensions/loader.ts`,
|
|
13
|
+
* `core/extensions/plugins/install.ts`, `core/skills.ts`,
|
|
14
|
+
* `core/prompt-templates.ts`); new callers should use this one.
|
|
15
|
+
*/
|
|
16
|
+
export declare function isPathInside(target: string, root: string): boolean;
|
|
8
17
|
/**
|
|
9
18
|
* Returns true if the value is NOT a package source (npm:, git:, etc.)
|
|
10
19
|
* or a URL protocol. Bare names and relative paths without ./ prefix
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"paths.d.ts","sourceRoot":"","sources":["../../src/utils/paths.ts"],"names":[],"mappings":"AAGA;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAMrD;AAED;;;;GAIG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAclD;AAMD,wBAAgB,kBAAkB,CAAC,QAAQ,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CASpF;AAED,wBAAgB,iCAAiC,CAAC,QAAQ,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,CAGvF;AAaD;;;;;GAKG;AACH,wBAAgB,yBAAyB,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,CAapF","sourcesContent":["import { existsSync, realpathSync } from \"node:fs\";\nimport { dirname, isAbsolute, join, relative, resolve as resolvePath, sep } from \"node:path\";\n\n/**\n * Resolve a path to its canonical (real) form, following symlinks.\n * Falls back to the raw path if resolution fails (e.g. the target does\n * not exist yet), so that callers never crash on missing filesystem\n * entries.\n */\nexport function canonicalizePath(path: string): string {\n\ttry {\n\t\treturn realpathSync(path);\n\t} catch {\n\t\treturn path;\n\t}\n}\n\n/**\n * Returns true if the value is NOT a package source (npm:, git:, etc.)\n * or a URL protocol. Bare names and relative paths without ./ prefix\n * are considered local.\n */\nexport function isLocalPath(value: string): boolean {\n\tconst trimmed = value.trim();\n\t// Known non-local prefixes\n\tif (\n\t\ttrimmed.startsWith(\"npm:\") ||\n\t\ttrimmed.startsWith(\"git:\") ||\n\t\ttrimmed.startsWith(\"github:\") ||\n\t\ttrimmed.startsWith(\"http:\") ||\n\t\ttrimmed.startsWith(\"https:\") ||\n\t\ttrimmed.startsWith(\"ssh:\")\n\t) {\n\t\treturn false;\n\t}\n\treturn true;\n}\n\nfunction resolveAgainstCwd(filePath: string, cwd: string): string {\n\treturn isAbsolute(filePath) ? resolvePath(filePath) : resolvePath(cwd, filePath);\n}\n\nexport function getCwdRelativePath(filePath: string, cwd: string): string | undefined {\n\tconst resolvedCwd = resolvePath(cwd);\n\tconst resolvedPath = resolveAgainstCwd(filePath, resolvedCwd);\n\tconst relativePath = relative(resolvedCwd, resolvedPath);\n\tconst isInsideCwd =\n\t\trelativePath === \"\" ||\n\t\t(relativePath !== \"..\" && !relativePath.startsWith(`..${sep}`) && !isAbsolute(relativePath));\n\n\treturn isInsideCwd ? relativePath || \".\" : undefined;\n}\n\nexport function formatPathRelativeToCwdOrAbsolute(filePath: string, cwd: string): string {\n\tconst absolutePath = resolveAgainstCwd(filePath, cwd);\n\treturn (getCwdRelativePath(absolutePath, cwd) ?? absolutePath).split(sep).join(\"/\");\n}\n\n/** Find the nearest ancestor directory containing a `.git` entry, or null. */\nfunction findGitRepoRoot(startDir: string): string | null {\n\tlet dir = resolvePath(startDir);\n\twhile (true) {\n\t\tif (existsSync(join(dir, \".git\"))) return dir;\n\t\tconst parent = dirname(dir);\n\t\tif (parent === dir) return null;\n\t\tdir = parent;\n\t}\n}\n\n/**\n * Collect `.agents/<subdir>/` directories walking from startDir up to the git\n * root, cwd-first (so a closer dir overrides an ancestor under first-match-wins).\n * When startDir is not inside a git repo, the walk continues to the filesystem\n * root, mirroring the agent/skill ancestor scanners.\n */\nexport function collectAgentsAncestorDirs(startDir: string, subdir: string): string[] {\n\tconst dirs: string[] = [];\n\tconst resolvedStart = resolvePath(startDir);\n\tconst gitRoot = findGitRepoRoot(resolvedStart);\n\tlet dir = resolvedStart;\n\twhile (true) {\n\t\tdirs.push(join(dir, \".agents\", subdir));\n\t\tif (gitRoot && dir === gitRoot) break;\n\t\tconst parent = dirname(dir);\n\t\tif (parent === dir) break;\n\t\tdir = parent;\n\t}\n\treturn dirs;\n}\n"]}
|
|
1
|
+
{"version":3,"file":"paths.d.ts","sourceRoot":"","sources":["../../src/utils/paths.ts"],"names":[],"mappings":"AAGA;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAMrD;AAED;;;;;;;GAOG;AACH,wBAAgB,YAAY,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAMlE;AAED;;;;GAIG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAclD;AAMD,wBAAgB,kBAAkB,CAAC,QAAQ,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CASpF;AAED,wBAAgB,iCAAiC,CAAC,QAAQ,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,CAGvF;AAaD;;;;;GAKG;AACH,wBAAgB,yBAAyB,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,CAapF","sourcesContent":["import { existsSync, realpathSync } from \"node:fs\";\nimport { dirname, isAbsolute, join, relative, resolve as resolvePath, sep } from \"node:path\";\n\n/**\n * Resolve a path to its canonical (real) form, following symlinks.\n * Falls back to the raw path if resolution fails (e.g. the target does\n * not exist yet), so that callers never crash on missing filesystem\n * entries.\n */\nexport function canonicalizePath(path: string): string {\n\ttry {\n\t\treturn realpathSync(path);\n\t} catch {\n\t\treturn path;\n\t}\n}\n\n/**\n * Whether `target` is `root` itself or sits inside it.\n *\n * Segment-aware, so `/a/bc` is not treated as being inside `/a/b`. Four private\n * copies of this predicate already exist (`core/extensions/loader.ts`,\n * `core/extensions/plugins/install.ts`, `core/skills.ts`,\n * `core/prompt-templates.ts`); new callers should use this one.\n */\nexport function isPathInside(target: string, root: string): boolean {\n\tconst normalizedRoot = resolvePath(root);\n\tconst normalizedTarget = resolvePath(target);\n\tif (normalizedTarget === normalizedRoot) return true;\n\tconst prefix = normalizedRoot.endsWith(sep) ? normalizedRoot : `${normalizedRoot}${sep}`;\n\treturn normalizedTarget.startsWith(prefix);\n}\n\n/**\n * Returns true if the value is NOT a package source (npm:, git:, etc.)\n * or a URL protocol. Bare names and relative paths without ./ prefix\n * are considered local.\n */\nexport function isLocalPath(value: string): boolean {\n\tconst trimmed = value.trim();\n\t// Known non-local prefixes\n\tif (\n\t\ttrimmed.startsWith(\"npm:\") ||\n\t\ttrimmed.startsWith(\"git:\") ||\n\t\ttrimmed.startsWith(\"github:\") ||\n\t\ttrimmed.startsWith(\"http:\") ||\n\t\ttrimmed.startsWith(\"https:\") ||\n\t\ttrimmed.startsWith(\"ssh:\")\n\t) {\n\t\treturn false;\n\t}\n\treturn true;\n}\n\nfunction resolveAgainstCwd(filePath: string, cwd: string): string {\n\treturn isAbsolute(filePath) ? resolvePath(filePath) : resolvePath(cwd, filePath);\n}\n\nexport function getCwdRelativePath(filePath: string, cwd: string): string | undefined {\n\tconst resolvedCwd = resolvePath(cwd);\n\tconst resolvedPath = resolveAgainstCwd(filePath, resolvedCwd);\n\tconst relativePath = relative(resolvedCwd, resolvedPath);\n\tconst isInsideCwd =\n\t\trelativePath === \"\" ||\n\t\t(relativePath !== \"..\" && !relativePath.startsWith(`..${sep}`) && !isAbsolute(relativePath));\n\n\treturn isInsideCwd ? relativePath || \".\" : undefined;\n}\n\nexport function formatPathRelativeToCwdOrAbsolute(filePath: string, cwd: string): string {\n\tconst absolutePath = resolveAgainstCwd(filePath, cwd);\n\treturn (getCwdRelativePath(absolutePath, cwd) ?? absolutePath).split(sep).join(\"/\");\n}\n\n/** Find the nearest ancestor directory containing a `.git` entry, or null. */\nfunction findGitRepoRoot(startDir: string): string | null {\n\tlet dir = resolvePath(startDir);\n\twhile (true) {\n\t\tif (existsSync(join(dir, \".git\"))) return dir;\n\t\tconst parent = dirname(dir);\n\t\tif (parent === dir) return null;\n\t\tdir = parent;\n\t}\n}\n\n/**\n * Collect `.agents/<subdir>/` directories walking from startDir up to the git\n * root, cwd-first (so a closer dir overrides an ancestor under first-match-wins).\n * When startDir is not inside a git repo, the walk continues to the filesystem\n * root, mirroring the agent/skill ancestor scanners.\n */\nexport function collectAgentsAncestorDirs(startDir: string, subdir: string): string[] {\n\tconst dirs: string[] = [];\n\tconst resolvedStart = resolvePath(startDir);\n\tconst gitRoot = findGitRepoRoot(resolvedStart);\n\tlet dir = resolvedStart;\n\twhile (true) {\n\t\tdirs.push(join(dir, \".agents\", subdir));\n\t\tif (gitRoot && dir === gitRoot) break;\n\t\tconst parent = dirname(dir);\n\t\tif (parent === dir) break;\n\t\tdir = parent;\n\t}\n\treturn dirs;\n}\n"]}
|
package/dist/utils/paths.js
CHANGED
|
@@ -14,6 +14,22 @@ export function canonicalizePath(path) {
|
|
|
14
14
|
return path;
|
|
15
15
|
}
|
|
16
16
|
}
|
|
17
|
+
/**
|
|
18
|
+
* Whether `target` is `root` itself or sits inside it.
|
|
19
|
+
*
|
|
20
|
+
* Segment-aware, so `/a/bc` is not treated as being inside `/a/b`. Four private
|
|
21
|
+
* copies of this predicate already exist (`core/extensions/loader.ts`,
|
|
22
|
+
* `core/extensions/plugins/install.ts`, `core/skills.ts`,
|
|
23
|
+
* `core/prompt-templates.ts`); new callers should use this one.
|
|
24
|
+
*/
|
|
25
|
+
export function isPathInside(target, root) {
|
|
26
|
+
const normalizedRoot = resolvePath(root);
|
|
27
|
+
const normalizedTarget = resolvePath(target);
|
|
28
|
+
if (normalizedTarget === normalizedRoot)
|
|
29
|
+
return true;
|
|
30
|
+
const prefix = normalizedRoot.endsWith(sep) ? normalizedRoot : `${normalizedRoot}${sep}`;
|
|
31
|
+
return normalizedTarget.startsWith(prefix);
|
|
32
|
+
}
|
|
17
33
|
/**
|
|
18
34
|
* Returns true if the value is NOT a package source (npm:, git:, etc.)
|
|
19
35
|
* or a URL protocol. Bare names and relative paths without ./ prefix
|