tinker-agent 2.6.0 → 2.8.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/CHANGELOG.md CHANGED
@@ -5,6 +5,43 @@ All notable user-facing changes to Tinker are documented here. The project follo
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [2.8.0] - 2026-09-04
9
+
10
+ ### Changed
11
+
12
+ - Refresh built-in tool descriptions so filesystem scope, local command
13
+ execution, waiting, and context swaps are stated more precisely to the model.
14
+ - Remove obsolete implementation roadmaps and reconcile the remaining design
15
+ documents with the behavior already delivered by the runtime.
16
+
17
+ ### Fixed
18
+
19
+ - Re-evaluate context pressure after a completed turn so automatic maintenance
20
+ still runs when closing the turn itself moves the active context above its
21
+ configured pressure threshold.
22
+
23
+ ## [2.7.0] - 2026-09-04
24
+
25
+ ### Added
26
+
27
+ - Add `AskUser`, an interactive tool that lets the agent pause on a material
28
+ ambiguity and present two to six complete choices in the TUI before resuming
29
+ the same turn with the selected answer.
30
+ - Add model-accessible `MemoryCreate`, `MemoryUpdate`, and `MemoryDelete` tools
31
+ for explicitly maintaining global memories shared across sessions and
32
+ workspaces.
33
+
34
+ ### Changed
35
+
36
+ - Redesign the session resume picker as a compact table, making session metadata
37
+ easier to scan while preserving keyboard navigation and search.
38
+
39
+ ### Fixed
40
+
41
+ - Give the long-history PTY resume fixture enough time on slower Linux CI
42
+ runners, avoiding a false timeout while the model is still producing the
43
+ expected response.
44
+
8
45
  ## [2.6.0] - 2026-09-03
9
46
 
10
47
  ### Added
@@ -324,7 +361,9 @@ All notable user-facing changes to Tinker are documented here. The project follo
324
361
  - First formal npm release under the `tinker-agent` package name with the `tinker`
325
362
  executable.
326
363
 
327
- [Unreleased]: https://github.com/ishowshao/tinker/compare/v2.6.0...HEAD
364
+ [Unreleased]: https://github.com/ishowshao/tinker/compare/v2.8.0...HEAD
365
+ [2.8.0]: https://github.com/ishowshao/tinker/releases/tag/v2.8.0
366
+ [2.7.0]: https://github.com/ishowshao/tinker/releases/tag/v2.7.0
328
367
  [2.6.0]: https://github.com/ishowshao/tinker/releases/tag/v2.6.0
329
368
  [2.5.0]: https://github.com/ishowshao/tinker/releases/tag/v2.5.0
330
369
  [2.4.0]: https://github.com/ishowshao/tinker/releases/tag/v2.4.0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tinker-agent",
3
- "version": "2.6.0",
3
+ "version": "2.8.0",
4
4
  "description": "A personal coding agent with an interactive TUI and one-shot CLI.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
package/src/agent/loop.ts CHANGED
@@ -384,6 +384,28 @@ export async function runAgent(input: RunAgentInput): Promise<RunAgentResult> {
384
384
  });
385
385
  }
386
386
 
387
+ if (toolCalls.some((call) => call.name === "AskUser") && toolCalls.length !== 1) {
388
+ const detail =
389
+ "AskUser must be the only tool call in an assistant response. Call it alone on the next iteration.";
390
+ const completions = toolCalls.map((call, index) => {
391
+ requireCallInIteration(call, iteration, index + 1);
392
+ return {
393
+ call,
394
+ kind: "synthetic" as const,
395
+ reason: "failed_active" as const,
396
+ detail,
397
+ };
398
+ });
399
+ input.ledger.commitToolCompletions(completions);
400
+ await input.runtimeSession.append({
401
+ type: "agent.iteration.finished",
402
+ ...iteration,
403
+ data: { outcome: "continue", toolCallCount: toolCalls.length },
404
+ });
405
+ input.runtimeSession.finishIterationForContinuation(iteration);
406
+ continue;
407
+ }
408
+
387
409
  for (let callIndex = 0; callIndex < toolCalls.length; callIndex += 1) {
388
410
  const call = requireToolCall(toolCalls, callIndex);
389
411
  requireCallInIteration(call, iteration, callIndex + 1);
@@ -80,6 +80,8 @@ import {
80
80
  type ContextStatusRawResult,
81
81
  type ContextSwapCandidatesRawResult,
82
82
  type ContextSwapRawResult,
83
+ type AskUserRequest,
84
+ type AskUserResponse,
83
85
  type ToolExecutor,
84
86
  } from "../tools/types";
85
87
  import type { TurnUndoResult } from "../tools/turn-undo-manager";
@@ -202,9 +204,20 @@ export type RuntimeSession = {
202
204
  subscribeBashGuard(listener: () => void): () => void;
203
205
  setYoloMode(enabled: boolean): void;
204
206
  resolveBashConfirmation(decision: "allow" | "deny"): Promise<void>;
207
+ askUser(): AskUserSnapshot;
208
+ subscribeAskUser(listener: () => void): () => void;
209
+ resolveAskUser(response: AskUserResolution): Promise<void>;
205
210
  dispose(reason: SessionDisposeReason): Promise<void>;
206
211
  };
207
212
 
213
+ export type AskUserSnapshot = {
214
+ readonly pending?: AskUserRequest;
215
+ };
216
+
217
+ export type AskUserResolution =
218
+ | { readonly outcome: "selected"; readonly selectedIndex: number }
219
+ | { readonly outcome: "dismissed" };
220
+
208
221
  export type BashGuardSource = "default" | "environment" | "cli" | "session";
209
222
 
210
223
  export type BashGuardSnapshot = {
@@ -322,8 +335,12 @@ type CommonRuntimeSessionInput = {
322
335
  toolingConfig?: PublicToolingConfig;
323
336
  memorySearch?: ToolExecutor;
324
337
  memoryGet?: ToolExecutor;
338
+ memoryCreate?: ToolExecutor;
339
+ memoryUpdate?: ToolExecutor;
340
+ memoryDelete?: ToolExecutor;
325
341
  completedTurnHook?: CompletedTurnHook;
326
342
  enableTurnUndo?: boolean;
343
+ enableAskUser?: boolean;
327
344
  bashGuard?: {
328
345
  readonly mode: "guard" | "yolo";
329
346
  readonly source: Exclude<BashGuardSource, "session">;
@@ -490,6 +507,16 @@ class DefaultRuntimeSession implements RuntimeSession {
490
507
  private bashGuardSource: BashGuardSource;
491
508
  private bashGuardSnapshot: BashGuardSnapshot;
492
509
  private readonly bashGuardListeners = new Set<() => void>();
510
+ private askUserSnapshot: AskUserSnapshot = Object.freeze({});
511
+ private readonly askUserListeners = new Set<() => void>();
512
+ private pendingAskUser?: {
513
+ readonly request: AskUserRequest;
514
+ readonly startedAt: number;
515
+ readonly call: ToolCallIdentity;
516
+ readonly resolve: (response: AskUserResponse) => void;
517
+ readonly reject: (error: unknown) => void;
518
+ readonly removeAbortListener: () => void;
519
+ };
493
520
  private assistantTextDeltaSinkDisabled = false;
494
521
  private pendingBashConfirmation?: {
495
522
  readonly command: string;
@@ -695,6 +722,15 @@ class DefaultRuntimeSession implements RuntimeSession {
695
722
  ...(input.enableTurnUndo === true ? { enableTurnUndo: true } : {}),
696
723
  webFetchRefiner: input.webFetchRefiner,
697
724
  toolingConfig: input.toolingConfig,
725
+ ...(input.enableAskUser === true
726
+ ? {
727
+ askUser: (
728
+ call: ToolCallIdentity,
729
+ request: AskUserRequest,
730
+ signal: AbortSignal,
731
+ ) => session.requestUserAnswer(call, request, signal),
732
+ }
733
+ : {}),
698
734
  bashGuard: {
699
735
  surface: input.bashGuard?.surface ?? "one-shot",
700
736
  confirm: (call, request, signal) =>
@@ -704,6 +740,15 @@ class DefaultRuntimeSession implements RuntimeSession {
704
740
  ? {}
705
741
  : { memorySearch: input.memorySearch }),
706
742
  ...(input.memoryGet === undefined ? {} : { memoryGet: input.memoryGet }),
743
+ ...(input.memoryCreate === undefined
744
+ ? {}
745
+ : { memoryCreate: input.memoryCreate }),
746
+ ...(input.memoryUpdate === undefined
747
+ ? {}
748
+ : { memoryUpdate: input.memoryUpdate }),
749
+ ...(input.memoryDelete === undefined
750
+ ? {}
751
+ : { memoryDelete: input.memoryDelete }),
707
752
  ...(session.skillCatalog.skills.size === 0
708
753
  ? {}
709
754
  : {
@@ -1204,6 +1249,119 @@ class DefaultRuntimeSession implements RuntimeSession {
1204
1249
  }
1205
1250
  }
1206
1251
 
1252
+ askUser(): AskUserSnapshot {
1253
+ return this.askUserSnapshot;
1254
+ }
1255
+
1256
+ subscribeAskUser(listener: () => void): () => void {
1257
+ this.askUserListeners.add(listener);
1258
+ return () => this.askUserListeners.delete(listener);
1259
+ }
1260
+
1261
+ async resolveAskUser(response: AskUserResolution): Promise<void> {
1262
+ const pending = this.pendingAskUser;
1263
+ if (pending === undefined) {
1264
+ throw new Error("No AskUser question is pending.");
1265
+ }
1266
+ let result: AskUserResponse;
1267
+ if (response.outcome === "selected") {
1268
+ if (!Number.isSafeInteger(response.selectedIndex)) {
1269
+ throw new Error("AskUser selectedIndex must be an integer.");
1270
+ }
1271
+ const option = pending.request.options[response.selectedIndex];
1272
+ if (option === undefined) {
1273
+ throw new Error("AskUser selectedIndex is out of range.");
1274
+ }
1275
+ result = { outcome: "selected", answer: option.description };
1276
+ } else {
1277
+ result = { outcome: "dismissed" };
1278
+ }
1279
+ this.pendingAskUser = undefined;
1280
+ this.askUserSnapshot = Object.freeze({});
1281
+ pending.removeAbortListener();
1282
+ await this.append({
1283
+ type: "tool.user_question.resolved",
1284
+ ...pending.call,
1285
+ data: {
1286
+ ...result,
1287
+ durationMs: Date.now() - pending.startedAt,
1288
+ },
1289
+ });
1290
+ pending.resolve(result);
1291
+ this.notifyAskUserListeners();
1292
+ }
1293
+
1294
+ private async requestUserAnswer(
1295
+ call: ToolCallIdentity,
1296
+ request: AskUserRequest,
1297
+ signal: AbortSignal,
1298
+ ): Promise<AskUserResponse> {
1299
+ if (this.pendingAskUser !== undefined) {
1300
+ throw new Error("Another AskUser question is already pending.");
1301
+ }
1302
+ if (this.pendingBashConfirmation !== undefined) {
1303
+ throw new Error("Cannot ask the user while a Bash confirmation is pending.");
1304
+ }
1305
+ if (signal.aborted) {
1306
+ throw cancellationError(signal);
1307
+ }
1308
+ const startedAt = Date.now();
1309
+ await this.append({
1310
+ type: "tool.user_question.requested",
1311
+ ...call,
1312
+ data: request,
1313
+ });
1314
+ return new Promise<AskUserResponse>((resolve, reject) => {
1315
+ const onAbort = () => {
1316
+ const pending = this.pendingAskUser;
1317
+ if (pending?.call.toolCallId !== call.toolCallId) {
1318
+ return;
1319
+ }
1320
+ this.pendingAskUser = undefined;
1321
+ this.askUserSnapshot = Object.freeze({});
1322
+ void this.append({
1323
+ type: "tool.user_question.resolved",
1324
+ ...call,
1325
+ data: {
1326
+ outcome: "cancelled",
1327
+ durationMs: Date.now() - startedAt,
1328
+ },
1329
+ }).finally(() => {
1330
+ reject(cancellationError(signal));
1331
+ this.notifyAskUserListeners();
1332
+ });
1333
+ };
1334
+ signal.addEventListener("abort", onAbort, { once: true });
1335
+ const immutableRequest = Object.freeze({
1336
+ question: request.question,
1337
+ options: Object.freeze(
1338
+ request.options.map((option) =>
1339
+ Object.freeze({ description: option.description }),
1340
+ ),
1341
+ ),
1342
+ });
1343
+ this.pendingAskUser = {
1344
+ request: immutableRequest,
1345
+ startedAt,
1346
+ call,
1347
+ resolve,
1348
+ reject,
1349
+ removeAbortListener: () => signal.removeEventListener("abort", onAbort),
1350
+ };
1351
+ this.askUserSnapshot = Object.freeze({ pending: immutableRequest });
1352
+ this.notifyAskUserListeners();
1353
+ if (signal.aborted) {
1354
+ onAbort();
1355
+ }
1356
+ });
1357
+ }
1358
+
1359
+ private notifyAskUserListeners(): void {
1360
+ for (const listener of this.askUserListeners) {
1361
+ listener();
1362
+ }
1363
+ }
1364
+
1207
1365
  async importImage(
1208
1366
  sourcePath: string,
1209
1367
  signal: AbortSignal,
@@ -2341,6 +2499,7 @@ class DefaultRuntimeSession implements RuntimeSession {
2341
2499
  }
2342
2500
  await this.settleClosedTurnSkills();
2343
2501
  if (result.status === "completed") {
2502
+ await this.evaluateClosedTurnContextPressure();
2344
2503
  await this.performAutomaticContextMaintenance();
2345
2504
  }
2346
2505
  return result;
@@ -2364,6 +2523,21 @@ class DefaultRuntimeSession implements RuntimeSession {
2364
2523
  }
2365
2524
  }
2366
2525
 
2526
+ private async evaluateClosedTurnContextPressure(): Promise<void> {
2527
+ const automation = this.requireContextAutomation();
2528
+ if (!automation.automaticSwapOnly) return;
2529
+
2530
+ const snapshot = this.requireContextManager().measureCurrent();
2531
+ await this.append({
2532
+ type: "context.usage.updated",
2533
+ sessionId: this.sessionId,
2534
+ data: { phase: "turn_close", snapshot },
2535
+ });
2536
+ if (snapshot.pressure !== "normal") {
2537
+ this.pendingAutomaticContextMaintenance = true;
2538
+ }
2539
+ }
2540
+
2367
2541
  private async performAutomaticContextMaintenance(): Promise<void> {
2368
2542
  if (!this.pendingAutomaticContextMaintenance) return;
2369
2543
  this.pendingAutomaticContextMaintenance = false;
@@ -118,6 +118,7 @@ export async function runTui(options: RunTuiOptions): Promise<void> {
118
118
  ),
119
119
  toolingConfig: options.publicConfig.tooling,
120
120
  enableTurnUndo: true,
121
+ enableAskUser: true,
121
122
  bashGuard: {
122
123
  mode: sessionConfig.bashGuardMode,
123
124
  source: sessionConfig.bashGuardSource,
@@ -134,6 +135,18 @@ export async function runTui(options: RunTuiOptions): Promise<void> {
134
135
  workspaceRoot,
135
136
  sessionId,
136
137
  }),
138
+ memoryCreate: memoryCoordinator.createCreateToolExecutor({
139
+ workspaceRoot,
140
+ sessionId,
141
+ }),
142
+ memoryUpdate: memoryCoordinator.createUpdateToolExecutor({
143
+ workspaceRoot,
144
+ sessionId,
145
+ }),
146
+ memoryDelete: memoryCoordinator.createDeleteToolExecutor({
147
+ workspaceRoot,
148
+ sessionId,
149
+ }),
137
150
  completedTurnHook: memoryCoordinator,
138
151
  }),
139
152
  };
@@ -323,7 +336,6 @@ export async function runTui(options: RunTuiOptions): Promise<void> {
323
336
  } finally {
324
337
  instance?.unmount();
325
338
  restoreStdin();
326
- memoryCoordinator?.dispose();
327
339
  if (controller !== undefined) {
328
340
  try {
329
341
  await controller.dispose(disposeReason);
@@ -337,6 +349,7 @@ export async function runTui(options: RunTuiOptions): Promise<void> {
337
349
  );
338
350
  }
339
351
  }
352
+ memoryCoordinator?.dispose();
340
353
  }
341
354
 
342
355
  if (primaryError !== undefined) {
@@ -140,6 +140,16 @@ export class StdoutEventPrinter implements EventSink {
140
140
  `tool.confirmation.resolved toolCallId=${event.toolCallId} decision=${event.data.decision} durationMs=${event.data.durationMs}\n`,
141
141
  );
142
142
  break;
143
+ case "tool.user_question.requested":
144
+ this.stdout.write(
145
+ `tool.user_question.requested toolCallId=${event.toolCallId} question=${JSON.stringify(event.data.question)} options=${event.data.options.length}\n`,
146
+ );
147
+ break;
148
+ case "tool.user_question.resolved":
149
+ this.stdout.write(
150
+ `tool.user_question.resolved toolCallId=${event.toolCallId} outcome=${event.data.outcome} durationMs=${event.data.durationMs}\n`,
151
+ );
152
+ break;
143
153
  case "mcp.server.connected":
144
154
  this.stdout.write(
145
155
  `mcp.server.connected name=${event.data.serverName} tools=${event.data.toolCount}\n`,
@@ -235,7 +245,11 @@ function formatToolRawResult(call: ToolCall, raw: ToolRawResult): string[] {
235
245
  case "context_maintenance":
236
246
  case "memory_search":
237
247
  case "memory_get":
248
+ case "memory_create":
249
+ case "memory_update":
250
+ case "memory_delete":
238
251
  case "wait":
252
+ case "ask_user":
239
253
  case "mcp":
240
254
  case "generic":
241
255
  return [];
@@ -46,7 +46,7 @@ export type SessionStartedData = {
46
46
  };
47
47
 
48
48
  export type ContextUsageUpdatedData = {
49
- phase: "initial" | "preflight" | "measured" | "revision";
49
+ phase: "initial" | "preflight" | "measured" | "turn_close" | "revision";
50
50
  snapshot: ContextUsageSnapshot;
51
51
  };
52
52
 
@@ -351,6 +351,14 @@ export type AgentEventDataMap = {
351
351
  decision: "allow" | "deny" | "cancelled";
352
352
  durationMs: number;
353
353
  };
354
+ "tool.user_question.requested": {
355
+ question: string;
356
+ options: readonly { description: string }[];
357
+ };
358
+ "tool.user_question.resolved":
359
+ | { outcome: "selected"; answer: string; durationMs: number }
360
+ | { outcome: "dismissed"; durationMs: number }
361
+ | { outcome: "cancelled"; durationMs: number };
354
362
  "agent.iteration.finished": {
355
363
  outcome: "continue" | "completed";
356
364
  toolCallCount: number;
@@ -448,6 +456,7 @@ export type AgentEventInput =
448
456
  "tool.started" | "tool.raw_result" | "tool.finished" | "tool.observation"
449
457
  >
450
458
  | ToolEventInput<"tool.confirmation.requested" | "tool.confirmation.resolved">
459
+ | ToolEventInput<"tool.user_question.requested" | "tool.user_question.resolved">
451
460
  | ToolEventInput<
452
461
  "bash.task.backgrounded" | "bash.task.stopping" | "bash.task.finished"
453
462
  >;
@@ -2,6 +2,9 @@ import type { SessionId, TurnId } from "../ids/runtime-id";
2
2
 
3
3
  export const MEMORY_SEARCH_TOOL_NAME = "MemorySearch" as const;
4
4
  export const MEMORY_GET_TOOL_NAME = "MemoryGet" as const;
5
+ export const MEMORY_CREATE_TOOL_NAME = "MemoryCreate" as const;
6
+ export const MEMORY_UPDATE_TOOL_NAME = "MemoryUpdate" as const;
7
+ export const MEMORY_DELETE_TOOL_NAME = "MemoryDelete" as const;
5
8
  export const MEMORY_SCHEMA_VERSION = 2 as const;
6
9
  export const MAX_MEMORY_TEXT_BYTES = 512;
7
10
  export const MAX_MEMORY_SUMMARY_BYTES = 4_096;
@@ -112,6 +115,35 @@ export type StoredMemoryRecord = StoredMemorySummary & {
112
115
  readonly sourceTurnId: string;
113
116
  };
114
117
 
118
+ export type StoredMemoryMutationRecord = StoredMemoryRecord & {
119
+ readonly embedding: Float32Array;
120
+ };
121
+
122
+ export type MemoryUpdateStoreResult =
123
+ | {
124
+ readonly ok: true;
125
+ readonly memoryId: string;
126
+ }
127
+ | {
128
+ readonly ok: false;
129
+ readonly code: "memory_not_found";
130
+ }
131
+ | {
132
+ readonly ok: false;
133
+ readonly code: "memory_duplicate";
134
+ readonly conflictMemoryId: string;
135
+ };
136
+
137
+ export type MemoryDeleteStoreResult =
138
+ | {
139
+ readonly ok: true;
140
+ readonly memoryId: string;
141
+ }
142
+ | {
143
+ readonly ok: false;
144
+ readonly code: "memory_not_found";
145
+ };
146
+
115
147
  export type MemoryExtractionRejectedCounts = {
116
148
  readonly duplicate: number;
117
149
  readonly secret: number;
@@ -175,10 +207,24 @@ export type MemoryGetDiagnostic = {
175
207
  readonly ms: number;
176
208
  };
177
209
 
210
+ export type MemoryMutationDiagnostic = {
211
+ readonly at: string;
212
+ readonly kind: "create" | "update" | "delete";
213
+ readonly outcome: "ok" | "failed" | "skipped";
214
+ readonly reason: string | null;
215
+ readonly workspace: string;
216
+ readonly sessionId: string;
217
+ readonly turnId: string;
218
+ readonly toolCallId: string;
219
+ readonly memoryId: string | null;
220
+ readonly ms: number;
221
+ };
222
+
178
223
  export type MemoryDiagnostic =
179
224
  | MemoryExtractionDiagnostic
180
225
  | MemorySearchDiagnostic
181
226
  | MemoryGetDiagnostic
227
+ | MemoryMutationDiagnostic
182
228
  | MemoryInitDiagnostic;
183
229
 
184
230
  export class MemoryError extends Error {