tinker-agent 2.6.0 → 2.7.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,28 @@ All notable user-facing changes to Tinker are documented here. The project follo
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [2.7.0] - 2026-09-04
9
+
10
+ ### Added
11
+
12
+ - Add `AskUser`, an interactive tool that lets the agent pause on a material
13
+ ambiguity and present two to six complete choices in the TUI before resuming
14
+ the same turn with the selected answer.
15
+ - Add model-accessible `MemoryCreate`, `MemoryUpdate`, and `MemoryDelete` tools
16
+ for explicitly maintaining global memories shared across sessions and
17
+ workspaces.
18
+
19
+ ### Changed
20
+
21
+ - Redesign the session resume picker as a compact table, making session metadata
22
+ easier to scan while preserving keyboard navigation and search.
23
+
24
+ ### Fixed
25
+
26
+ - Give the long-history PTY resume fixture enough time on slower Linux CI
27
+ runners, avoiding a false timeout while the model is still producing the
28
+ expected response.
29
+
8
30
  ## [2.6.0] - 2026-09-03
9
31
 
10
32
  ### Added
@@ -324,7 +346,8 @@ All notable user-facing changes to Tinker are documented here. The project follo
324
346
  - First formal npm release under the `tinker-agent` package name with the `tinker`
325
347
  executable.
326
348
 
327
- [Unreleased]: https://github.com/ishowshao/tinker/compare/v2.6.0...HEAD
349
+ [Unreleased]: https://github.com/ishowshao/tinker/compare/v2.7.0...HEAD
350
+ [2.7.0]: https://github.com/ishowshao/tinker/releases/tag/v2.7.0
328
351
  [2.6.0]: https://github.com/ishowshao/tinker/releases/tag/v2.6.0
329
352
  [2.5.0]: https://github.com/ishowshao/tinker/releases/tag/v2.5.0
330
353
  [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.7.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,
@@ -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 [];
@@ -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 {