@zgeoff/atc 2.41.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zgeoff/atc",
3
- "version": "2.41.0",
3
+ "version": "3.0.0",
4
4
  "description": "Terminal control tower for coding-agent sessions",
5
5
  "homepage": "https://github.com/zgeoff/atc#readme",
6
6
  "bugs": "https://github.com/zgeoff/atc/issues",
@@ -38,6 +38,7 @@ export const ID_RULES: Readonly<Record<string, ReadonlyMap<string, IDRule>>> = {
38
38
  'session.update': new Map(),
39
39
  'session.kill': new Map(),
40
40
  'session.ack': new Map(),
41
+ 'session.forget': new Map([['confirmToken', 'opaque']]),
41
42
  'session.list': buildPrefixedRules('sessions[].', DESCRIPTOR_RULES),
42
43
  'session.spawn': buildPrefixedRules('session.', DESCRIPTOR_RULES),
43
44
  'session.get': buildPrefixedRules('session.', DESCRIPTOR_RULES),
@@ -68,13 +69,15 @@ export const ID_RULES: Readonly<Record<string, ReadonlyMap<string, IDRule>>> = {
68
69
  /**
69
70
  * The rule for every id-bearing field of an error's `data`, whatever the
70
71
  * method: `effectRef` holds the session or message an uncertain or
71
- * conflicting keyed request made.
72
+ * conflicting keyed request made, and `host` holds the session that owns
73
+ * a host a refusal concerns.
72
74
  */
73
75
  export const ERROR_DATA_RULES: ReadonlyMap<string, IDRule> = new Map([
74
76
  ['effectRef', 'id'],
75
77
  ['session', 'id'],
76
78
  ['message', 'id'],
77
79
  ['parent', 'id'],
80
+ ['host', 'id'],
78
81
  ]);
79
82
 
80
83
  function buildPrefixedRules(
@@ -474,6 +474,31 @@ export const MCP_TOOLS: readonly MCPToolDefinition[] = [
474
474
  description: 'Kill a session. A second kill on a dead session removes it from the list.',
475
475
  inputSchema: SESSION_INPUT,
476
476
  },
477
+ {
478
+ name: 'atc_session_forget',
479
+ annotations: DESTRUCTIVE,
480
+ scope: 'kill',
481
+ description:
482
+ 'Forget a session for good: it leaves the list. A live local sub-session of the session is not stopped: it stays alive and moves to the top level. On a target that can destroy its host (an imp), the first call changes nothing and returns { confirmToken, expiresAt }, a token good for 60 seconds; a second call with that token destroys the host and returns { forgotten: true, destroyed: true }, except that a sub-session on the imp host of its parent does not destroy that host and returns destroyed: false. On any other target one call forgets and returns { forgotten: true, destroyed: false }. A live session is refused unless stop is true, which stops it as part of the forget. A pinned session, or a sub-session of a pinned session, is refused: unpin it with atc_session_update first.',
483
+ inputSchema: {
484
+ type: 'object',
485
+ properties: {
486
+ session: { type: 'string', description: 'The atc session id' },
487
+ confirmToken: {
488
+ type: 'string',
489
+ minLength: 1,
490
+ description: 'The token an earlier call on the same session returned',
491
+ },
492
+ stop: {
493
+ type: 'boolean',
494
+ description: 'Stop the session when it is live; omit or false refuses a live session',
495
+ },
496
+ },
497
+ required: ['session'],
498
+ additionalProperties: false,
499
+ },
500
+ requires: { tool: 'session.forget' },
501
+ },
477
502
  {
478
503
  name: 'atc_session_ack',
479
504
  annotations: ADDITIVE,
@@ -10,7 +10,7 @@ const FEATURE_USES: Readonly<Record<DaemonFeature, string>> = {
10
10
  'message.idempotency': "atc_session_message's idempotencyKey",
11
11
  'message.turn': "atc_message_get's turn and answeredWith",
12
12
  'message.wait': "atc_message_get's waitMs",
13
- 'session.forget': 'session.forget',
13
+ 'session.forget': 'atc_session_forget',
14
14
  'session.locator': "a session's locator",
15
15
  'session.submit': 'atc_session_input',
16
16
  'spawn.idempotency': "atc_session_spawn's idempotencyKey",
@@ -146,6 +146,22 @@ export function runTool(
146
146
 
147
147
  return { text: 'killed', structured: null };
148
148
  })
149
+ .with('atc_session_forget', async () => {
150
+ await requireForgettable(caller, args['session'], args['stop'] === true);
151
+
152
+ const ok = await caller.sendRequest(
153
+ 'session.forget',
154
+ {
155
+ session: args['session'],
156
+ ...(typeof args['confirmToken'] === 'string'
157
+ ? { confirmToken: args['confirmToken'] }
158
+ : {}),
159
+ },
160
+ ['session.forget'],
161
+ );
162
+
163
+ return buildObjectResult(ok);
164
+ })
149
165
  .with('atc_session_ack', async () => {
150
166
  await caller.sendRequest('session.ack', { session: args['session'] });
151
167
 
@@ -269,6 +285,37 @@ function buildObjectResult(value: unknown): ToolResult {
269
285
  };
270
286
  }
271
287
 
288
+ // A forget is for good, so it reads the session first: an unseen or unknown
289
+ // session fails here before the daemon mints a token, and a pinned or live
290
+ // session is refused with the step that unblocks it.
291
+ async function requireForgettable(
292
+ caller: FleetCaller,
293
+ session: unknown,
294
+ stops: boolean,
295
+ ): Promise<void> {
296
+ const got = await caller.sendRequest('session.get', { session });
297
+
298
+ const descriptor = isRecord(got['session']) ? got['session'] : {};
299
+ const parent = descriptor['parent'];
300
+ let pinned = descriptor['pinned'] === true;
301
+
302
+ if (!pinned && typeof parent === 'string') {
303
+ const owner = await caller.sendRequest('session.get', { session: parent });
304
+
305
+ pinned = isRecord(owner['session']) && owner['session']['pinned'] === true;
306
+ }
307
+
308
+ if (pinned) {
309
+ throw new Error(
310
+ 'session_pinned: the session is pinned, or is a sub-session of a pinned session. Unpin it with atc_session_update before forgetting it.',
311
+ );
312
+ }
313
+
314
+ if (descriptor['alive'] === true && !stops) {
315
+ throw new Error('session_live: the session is live. Pass stop: true to stop and forget it.');
316
+ }
317
+ }
318
+
272
319
  // The inherited id can point at a session another daemon hosts, or one
273
320
  // this daemon no longer lists; the spawn then lands top-level instead of
274
321
  // failing the tool call. The top-level spawn has a different payload, so it