atom-agent 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/CHANGELOG.md +75 -0
  2. package/README.md +13 -4
  3. package/atom.example.json +11 -0
  4. package/dist/App.js +923 -200
  5. package/dist/adapters.js +82 -13
  6. package/dist/agent/goal-evaluator.js +69 -0
  7. package/dist/agent/loop.js +517 -76
  8. package/dist/cli.js +11 -3
  9. package/dist/compact.js +41 -15
  10. package/dist/config.js +43 -7
  11. package/dist/context-manager.js +16 -198
  12. package/dist/context-windows.js +4 -2
  13. package/dist/env-block.js +5 -5
  14. package/dist/extension-commands.js +196 -0
  15. package/dist/extension-ui.js +153 -0
  16. package/dist/extensions.js +1571 -0
  17. package/dist/goal.js +583 -0
  18. package/dist/project-trust.js +96 -0
  19. package/dist/providers.js +6 -6
  20. package/dist/scheduler.js +74 -36
  21. package/dist/session.js +23 -5
  22. package/dist/sessions.js +25 -6
  23. package/dist/telemetry-dashboard.js +28 -0
  24. package/dist/telemetry.js +39 -0
  25. package/dist/tools/compaction-hooks.js +165 -0
  26. package/dist/tools/custom.js +189 -0
  27. package/dist/tools/intercept.js +145 -0
  28. package/dist/tools/overrides.js +105 -0
  29. package/dist/tools/provider-hooks.js +224 -0
  30. package/dist/tools/registry.js +246 -17
  31. package/dist/tools.js +44 -0
  32. package/dist/ui/palette.js +1 -1
  33. package/dist/ui/status-bar.js +80 -5
  34. package/dist/zen.js +305 -75
  35. package/documentation/architecture.md +114 -0
  36. package/documentation/cli.md +82 -0
  37. package/documentation/compaction.md +50 -0
  38. package/documentation/configuration.md +111 -0
  39. package/documentation/development.md +62 -0
  40. package/documentation/extensions.md +160 -0
  41. package/documentation/getting-started.md +63 -0
  42. package/documentation/goals.md +41 -0
  43. package/documentation/index.md +41 -0
  44. package/documentation/observability.md +70 -0
  45. package/documentation/permissions.md +66 -0
  46. package/documentation/providers.md +78 -0
  47. package/documentation/sessions.md +92 -0
  48. package/documentation/skills.md +57 -0
  49. package/documentation/tools.md +94 -0
  50. package/documentation/troubleshooting.md +54 -0
  51. package/examples/extensions/01-audit-gate.js +24 -0
  52. package/examples/extensions/02-notes-tool.js +32 -0
  53. package/examples/extensions/03-custom-command.js +32 -0
  54. package/package.json +6 -2
@@ -0,0 +1,196 @@
1
+ // Extension slash commands (ticket 04): the runtime store behind
2
+ // ExtensionAPI.registerCommand. Dependency-free (no imports) like
3
+ // tools/custom.ts and tools/intercept.ts, so the extension host and the App
4
+ // slash dispatch share it with no cycle: extensions register here, the App
5
+ // lists/parses/runs here, nobody imports the other.
6
+ //
7
+ // A command is a real slash command (`/name args...`): it appears in the
8
+ // command palette and the "/" menu, takes typed free-text arguments, and
9
+ // runs extension code OUTSIDE the model turn loop with a generation-bound
10
+ // context (prompt the user, read a session snapshot, post messages).
11
+ //
12
+ // Semantics:
13
+ // - Names are bare in the definition ("deploy") and slash-prefixed at the
14
+ // seam ("/deploy"). Lowercase [a-z0-9_-] only, mirroring builtin style so
15
+ // menu/palette matching stays exact and case-free.
16
+ // - Collision decision: builtins always win. The host rejects a colliding
17
+ // name at activation (loud error, nothing commits) and App dispatch
18
+ // routes builtins first as backstop — a builtin is never shadowed, and
19
+ // there is no renamed form to discover. Duplicate extension names throw
20
+ // the same way (first registration wins, deterministically by load order).
21
+ // - Handlers never touch model history: say() stages messages and the
22
+ // runner commits them only on success. A throwing handler drops the
23
+ // stage and surfaces a clean `Error:` string — the session is untouched.
24
+ // - The context is generation-bound: every ctx call runs checkStale first,
25
+ // so use after a session replacement throws loudly instead of acting on
26
+ // the wrong session (same rule as the event API).
27
+ const NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/;
28
+ const store = new Map();
29
+ function isRecord(value) {
30
+ return typeof value === "object" && value !== null && !Array.isArray(value);
31
+ }
32
+ function errorText(e) {
33
+ return e instanceof Error ? e.message : String(e ?? "unknown error");
34
+ }
35
+ /**
36
+ * Validate a registration shape. Throws Error on any problem (bad name,
37
+ * empty description, non-function handler). Duplicate and builtin-
38
+ * collision checks live with the callers that own those names (the store
39
+ * owns extension names; the host owns the builtin set).
40
+ */
41
+ export function validateExtensionCommandDef(def) {
42
+ if (!isRecord(def))
43
+ throw new Error("extension command definition must be an object");
44
+ if (typeof def.name !== "string" || !NAME_RE.test(def.name)) {
45
+ throw new Error(`extension command has an invalid name ${JSON.stringify(def.name)} (want a bare 1-64 char a-z0-9_- name, no leading slash)`);
46
+ }
47
+ if (typeof def.description !== "string" || def.description.trim().length === 0) {
48
+ throw new Error(`extension command "/${def.name}" needs a non-empty description`);
49
+ }
50
+ if (typeof def.handler !== "function") {
51
+ throw new Error(`extension command "/${def.name}" needs a handler function`);
52
+ }
53
+ }
54
+ /** Register a validated command. Throws on duplicate names. Returns an unregister function. */
55
+ export function registerExtensionCommand(def, owner = "(unknown)") {
56
+ validateExtensionCommandDef(def);
57
+ if (store.has(def.name)) {
58
+ throw new Error(`extension command "/${def.name}" is already registered`);
59
+ }
60
+ const rec = {
61
+ name: def.name,
62
+ description: def.description,
63
+ handler: def.handler,
64
+ owner,
65
+ };
66
+ store.set(def.name, rec);
67
+ let live = true;
68
+ return () => {
69
+ if (!live)
70
+ return;
71
+ live = false;
72
+ if (store.get(def.name) === rec)
73
+ store.delete(def.name);
74
+ };
75
+ }
76
+ export function unregisterExtensionCommand(name) {
77
+ return store.delete(name);
78
+ }
79
+ export function getExtensionCommand(name) {
80
+ return store.get(name);
81
+ }
82
+ /** Live commands in registration order (deterministic menu/palette order). */
83
+ export function listExtensionCommands() {
84
+ return [...store.values()];
85
+ }
86
+ /** Test seam: drop every extension command. */
87
+ export function clearExtensionCommands() {
88
+ store.clear();
89
+ }
90
+ // Split "/name args..." into its bare name and raw args. Returns null for
91
+ // anything that is not a single-line slash invocation (plain text, a bare
92
+ // "/", multiline, namespaced skill forms like "/skill:dep" — the name must
93
+ // be followed by whitespace or end, so skill routing is never disturbed).
94
+ export function parseExtensionCommandInput(text) {
95
+ if (!text.startsWith("/") || text.length < 2)
96
+ return null;
97
+ if (/[\r\n]/.test(text))
98
+ return null;
99
+ const match = /^\/([A-Za-z0-9_-]+)(?:[ \t]+([\s\S]*))?$/.exec(text);
100
+ if (!match)
101
+ return null;
102
+ return { name: match[1], args: (match[2] ?? "").trim() };
103
+ }
104
+ // Whitespace tokenizer for ctx.argv: double/single quotes group words, the
105
+ // quotes are stripped, no escape processing (documented, not shell).
106
+ export function splitCommandArgs(args) {
107
+ const out = [];
108
+ let cur = "";
109
+ let quote = null;
110
+ let has = false;
111
+ for (let i = 0; i < args.length; i++) {
112
+ const ch = args[i];
113
+ if (quote) {
114
+ if (ch === quote) {
115
+ quote = null;
116
+ }
117
+ else {
118
+ cur += ch;
119
+ }
120
+ has = true;
121
+ continue;
122
+ }
123
+ if (ch === '"' || ch === "'") {
124
+ quote = ch;
125
+ has = true;
126
+ continue;
127
+ }
128
+ if (ch === " " || ch === "\t") {
129
+ if (has) {
130
+ out.push(cur);
131
+ cur = "";
132
+ has = false;
133
+ }
134
+ continue;
135
+ }
136
+ cur += ch;
137
+ has = true;
138
+ }
139
+ if (has)
140
+ out.push(cur);
141
+ return out;
142
+ }
143
+ // Run a registered command outside the model turn loop. Never throws for
144
+ // handler failures: unknown names and throwing handlers return a clean
145
+ // `Error:` result. say() output stages in a buffer and commits through
146
+ // deps.say only on success, so a throwing handler leaves the session
147
+ // untouched. Every context call runs checkStale first — stale use throws
148
+ // into the same clean-error path instead of acting on the wrong session.
149
+ export async function runExtensionCommand(name, rawArgs, deps) {
150
+ const rec = store.get(name);
151
+ if (!rec) {
152
+ return { ok: false, error: `Error: unknown extension command "/${name}" (not registered)` };
153
+ }
154
+ const stale = deps.checkStale ?? (() => undefined);
155
+ const staged = [];
156
+ const ctx = {
157
+ name,
158
+ args: rawArgs,
159
+ argv: splitCommandArgs(rawArgs),
160
+ cwd: deps.cwd ?? process.cwd(),
161
+ askUser: async (question, options, allowCustom) => {
162
+ stale();
163
+ return deps.askUser(question, options, allowCustom);
164
+ },
165
+ getSession: () => {
166
+ stale();
167
+ return deps.getSession();
168
+ },
169
+ say: (message) => {
170
+ stale();
171
+ if (typeof message !== "string") {
172
+ throw new Error(`extension command "/${name}" say() needs a string`);
173
+ }
174
+ if (message.length === 0)
175
+ return;
176
+ staged.push(message);
177
+ },
178
+ };
179
+ let returned;
180
+ try {
181
+ returned = await rec.handler(ctx);
182
+ }
183
+ catch (e) {
184
+ return { ok: false, error: `Error: extension command "/${name}" failed: ${errorText(e)}` };
185
+ }
186
+ if (typeof returned === "string" && returned.length > 0)
187
+ staged.push(returned);
188
+ try {
189
+ for (const message of staged)
190
+ deps.say(message);
191
+ }
192
+ catch (e) {
193
+ return { ok: false, error: `Error: extension command "/${name}" failed: ${errorText(e)}` };
194
+ }
195
+ return { ok: true, posted: staged.length };
196
+ }
@@ -0,0 +1,153 @@
1
+ // Extension UI surface (ticket 10): the dependency-free render model behind
2
+ // ExtensionAPI.setStatusSegment/setWidget/notify/promptUser. No imports, no
3
+ // React — like extension-commands.ts and tools/intercept.ts — so the host,
4
+ // the App, and pure unit tests share it with no cycle: the host validates
5
+ // and stages here, the App renders from here, nobody imports the other.
6
+ //
7
+ // Shapes:
8
+ // - Status: one text slot per extension (upsert by owner name). The bar
9
+ // budget lives with the render (status-bar.tsx states the fixed-width
10
+ // rule); this module only validates shape so a bad segment fails
11
+ // activation loudly instead of corrupting the bar.
12
+ // - Widget: titled text blocks keyed by owner + id (default "main"),
13
+ // rendered by the App in the configured placement. "panel" is the only
14
+ // placement in v1 (the bordered panel above the input zone, beside the
15
+ // todo panel) — unknown placements throw fail-closed so a typo surfaces
16
+ // at activation instead of rendering nowhere.
17
+ // - Notices: transient fire-and-forget strings the App drains into the
18
+ // transcript (one `(owner) message` info line each). Sync and bounded
19
+ // by flow — notify() never blocks, headless or not; the host caps the
20
+ // staged queue drop-oldest (EXT_NOTICE_CAP in extensions.ts).
21
+ // - Dialogs: validated { question, options, allowCustom } specs mirroring
22
+ // the QuestionBox the App fulfills them with (same option caps the modal
23
+ // already assumes). The pending-promise mechanics live in extensions.ts
24
+ // (runtime-local, generation-bound); this module only validates shape.
25
+ export const EXT_STATUS_SEGMENT_MAX = 24;
26
+ export const EXT_STATUS_TOTAL_MAX = 40;
27
+ export const EXT_WIDGET_TITLE_MAX = 48;
28
+ export const EXT_WIDGET_TEXT_MAX = 500;
29
+ export const EXT_NOTIFY_MAX = 200;
30
+ export const EXT_DIALOG_QUESTION_MAX = 200;
31
+ export const EXT_DIALOG_OPTIONS_MAX = 8;
32
+ export const EXT_DIALOG_OPTION_MAX = 80;
33
+ // v1 widget placements. The App renders "panel" above the input zone;
34
+ // anything else is a loud validation error (never a silent nowhere).
35
+ export const EXT_WIDGET_PLACEMENTS = ["panel"];
36
+ const WIDGET_ID_RE = /^[a-z0-9][a-z0-9_-]{0,31}$/;
37
+ function isRecord(value) {
38
+ return typeof value === "object" && value !== null && !Array.isArray(value);
39
+ }
40
+ function errorFor(owner, what) {
41
+ return `extension "${owner}" ${what}`;
42
+ }
43
+ /** Validate a status segment. Throws Error on any problem. Returns the trimmed text. */
44
+ export function validateStatusSegment(owner, text) {
45
+ if (typeof text !== "string" || text.trim().length === 0) {
46
+ throw new Error(errorFor(owner, "status segment needs a non-empty string"));
47
+ }
48
+ if (text.length > EXT_STATUS_SEGMENT_MAX * 4) {
49
+ throw new Error(errorFor(owner, `status segment is too long (${text.length} chars, max ${EXT_STATUS_SEGMENT_MAX * 4})`));
50
+ }
51
+ return text;
52
+ }
53
+ /** Validate a widget definition. Throws Error on any problem. */
54
+ export function validateWidgetDef(owner, def) {
55
+ if (!isRecord(def))
56
+ throw new Error(errorFor(owner, "widget definition must be an object"));
57
+ const id = def.id === undefined ? "main" : def.id;
58
+ if (typeof id !== "string" || !WIDGET_ID_RE.test(id)) {
59
+ throw new Error(errorFor(owner, `widget has an invalid id ${JSON.stringify(def.id)} (want 1-32 char a-z0-9_-, default "main")`));
60
+ }
61
+ if (!EXT_WIDGET_PLACEMENTS.includes(def.placement)) {
62
+ throw new Error(errorFor(owner, `widget "${id}" has an unknown placement ${JSON.stringify(def.placement)} (want one of: ${EXT_WIDGET_PLACEMENTS.join(", ")})`));
63
+ }
64
+ if (typeof def.title !== "string" || def.title.trim().length === 0) {
65
+ throw new Error(errorFor(owner, `widget "${id}" needs a non-empty title`));
66
+ }
67
+ if (def.title.length > EXT_WIDGET_TITLE_MAX) {
68
+ throw new Error(errorFor(owner, `widget "${id}" title is too long (${def.title.length} chars, max ${EXT_WIDGET_TITLE_MAX})`));
69
+ }
70
+ if (typeof def.text !== "string" || def.text.trim().length === 0) {
71
+ throw new Error(errorFor(owner, `widget "${id}" needs a non-empty text body`));
72
+ }
73
+ if (def.text.length > EXT_WIDGET_TEXT_MAX) {
74
+ throw new Error(errorFor(owner, `widget "${id}" text is too long (${def.text.length} chars, max ${EXT_WIDGET_TEXT_MAX})`));
75
+ }
76
+ return { id, placement: def.placement, title: def.title, text: def.text };
77
+ }
78
+ /** Validate a notification message. Throws Error on any problem. Returns the message. */
79
+ export function validateNotifyMessage(owner, message) {
80
+ if (typeof message !== "string" || message.trim().length === 0) {
81
+ throw new Error(errorFor(owner, "notification needs a non-empty string"));
82
+ }
83
+ if (message.length > EXT_NOTIFY_MAX * 4) {
84
+ throw new Error(errorFor(owner, `notification is too long (${message.length} chars, max ${EXT_NOTIFY_MAX * 4})`));
85
+ }
86
+ return message;
87
+ }
88
+ /** Validate a dialog spec. Throws Error on any problem. */
89
+ export function validateDialogDef(owner, def) {
90
+ if (!isRecord(def))
91
+ throw new Error(errorFor(owner, "dialog definition must be an object"));
92
+ if (typeof def.question !== "string" || def.question.trim().length === 0) {
93
+ throw new Error(errorFor(owner, "dialog needs a non-empty question"));
94
+ }
95
+ if (def.question.length > EXT_DIALOG_QUESTION_MAX) {
96
+ throw new Error(errorFor(owner, `dialog question is too long (${def.question.length} chars, max ${EXT_DIALOG_QUESTION_MAX})`));
97
+ }
98
+ const options = def.options === undefined ? [] : def.options;
99
+ if (!Array.isArray(options)) {
100
+ throw new Error(errorFor(owner, "dialog options must be an array of strings"));
101
+ }
102
+ if (options.length > EXT_DIALOG_OPTIONS_MAX) {
103
+ throw new Error(errorFor(owner, `dialog has too many options (${options.length}, max ${EXT_DIALOG_OPTIONS_MAX})`));
104
+ }
105
+ for (const o of options) {
106
+ if (typeof o !== "string" || o.trim().length === 0) {
107
+ throw new Error(errorFor(owner, "dialog options must be non-empty strings"));
108
+ }
109
+ if (o.length > EXT_DIALOG_OPTION_MAX) {
110
+ throw new Error(errorFor(owner, `dialog option is too long (${o.length} chars, max ${EXT_DIALOG_OPTION_MAX})`));
111
+ }
112
+ }
113
+ if (!options.length && def.allowCustom !== true) {
114
+ throw new Error(errorFor(owner, "dialog needs options or allowCustom: true (nothing to answer with)"));
115
+ }
116
+ return { question: def.question, options: [...options], allowCustom: def.allowCustom === true };
117
+ }
118
+ // Truncate text to n chars max for tight widths (`…/tail` keeps the
119
+ // meaningful end, the status-bar convention). n < 4 yields "" (the caller
120
+ // drops the segment instead of rendering a stub).
121
+ export function truncateSegment(s, n) {
122
+ if (s.length <= n)
123
+ return s;
124
+ if (n < 4)
125
+ return "";
126
+ return `…/${s.slice(-(n - 3))}`;
127
+ }
128
+ // Pure status-bar text for extension segments (unit-tested; the bar itself
129
+ // only decides fit-or-drop against the terminal width, never the content).
130
+ // Each segment truncates to EXT_STATUS_SEGMENT_MAX, joined with " · "; the
131
+ // total caps at EXT_STATUS_TOTAL_MAX with trailing segments dropped whole
132
+ // (never a mid-segment cut past the per-segment truncation). Null when
133
+ // nothing renderable remains.
134
+ export function formatExtensionStatusText(segments) {
135
+ const parts = [];
136
+ let len = 0;
137
+ for (const raw of segments) {
138
+ if (typeof raw !== "string")
139
+ continue;
140
+ const text = raw.trim();
141
+ if (!text)
142
+ continue;
143
+ const seg = truncateSegment(text, EXT_STATUS_SEGMENT_MAX);
144
+ if (!seg)
145
+ continue;
146
+ const add = (parts.length > 0 ? 3 : 0) + seg.length;
147
+ if (len + add > EXT_STATUS_TOTAL_MAX)
148
+ continue;
149
+ parts.push(seg);
150
+ len += add;
151
+ }
152
+ return parts.length > 0 ? parts.join(" · ") : null;
153
+ }