devez-vibe 0.1.48 → 1.2.1

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/bin/dvz.exe CHANGED
Binary file
@@ -14,6 +14,10 @@ import {
14
14
 
15
15
  const VERSION = process.env.DEVEZ_VIBE_VERSION || "dev";
16
16
  const sessions = new Map();
17
+ // The id this bridge proposes is not always the id the CLI persists the
18
+ // transcript under, so a session can be renamed mid-flight. Old id → live id,
19
+ // which keeps ids the host already handed out (or wrote to disk) resolvable.
20
+ const sessionAliases = new Map();
17
21
  const pendingHostRequests = new Map();
18
22
  const modelCatalogs = new Map();
19
23
  let nextHostRequest = 1;
@@ -100,6 +104,21 @@ function applyClaudeExecutable(options, params) {
100
104
  options.pathToClaudeCodeExecutable = executable;
101
105
  }
102
106
 
107
+ // The SDK model list carries no window size, so the status line would stay
108
+ // blank until a turn reports one — and a fresh session has no turn yet. Claude
109
+ // ships a 200k window, and the `[1m]` variants a 1M one.
110
+ function claudeContextWindow(...names) {
111
+ const oneMillion = names.some((name) => String(name || "").includes("[1m]"));
112
+ return oneMillion ? 1_000_000 : 200_000;
113
+ }
114
+
115
+ function capabilityContextWindow(capabilities, ...names) {
116
+ const reported = Number(capabilities?.contextWindow || capabilities?.contextWindowSize || 0);
117
+ return reported > 0
118
+ ? reported
119
+ : claudeContextWindow(...names, capabilities?.value, capabilities?.resolvedModel);
120
+ }
121
+
103
122
  function modelCapabilities(models, model) {
104
123
  const value = stripClaudeModel(model);
105
124
  return models.find((candidate) => candidate.value === value || candidate.resolvedModel === value)
@@ -141,7 +160,7 @@ function catalogEntry(model, defaultResolvedModel) {
141
160
  const efforts = model.supportsEffort && Array.isArray(model.supportedEffortLevels)
142
161
  ? model.supportedEffortLevels
143
162
  : [];
144
- const contextWindow = Number(model.contextWindow || model.contextWindowSize || 0);
163
+ const contextWindow = capabilityContextWindow(model, value, resolved);
145
164
  return {
146
165
  id: visibleModel(resolved),
147
166
  model: visibleModel(value),
@@ -215,11 +234,78 @@ function rawSession(id) {
215
234
  return id.startsWith("claude:") ? id.slice("claude:".length) : id;
216
235
  }
217
236
 
237
+ /** Follows the rename chain from an id the host still remembers to the live one. */
238
+ function liveSessionId(id) {
239
+ let current = rawSession(String(id ?? ""));
240
+ const seen = new Set();
241
+ while (sessionAliases.has(current) && !seen.has(current)) {
242
+ seen.add(current);
243
+ current = sessionAliases.get(current);
244
+ }
245
+ return current;
246
+ }
247
+
248
+ function lookupSession(id) {
249
+ return sessions.get(liveSessionId(id));
250
+ }
251
+
252
+ /**
253
+ * Binds the session to the id the CLI actually persists under. `options.sessionId`
254
+ * is a request, not a guarantee: a session that gets rotated (a pre-warmed process
255
+ * the first turn does not reuse, a resume the CLI declines) writes its transcript
256
+ * under a different uuid, and everything downstream — `session/resume`,
257
+ * `session/history`, DevezCode's `-r` on the next launch — keys off the persisted
258
+ * id. Adopting it here and telling the host is what keeps a Claude-backed session
259
+ * resumable at all.
260
+ */
261
+ function adoptSessionId(session, incoming) {
262
+ const real = rawSession(String(incoming ?? ""));
263
+ if (!real || real === session.id) return;
264
+ const previous = session.id;
265
+ sessions.delete(previous);
266
+ sessionAliases.set(previous, real);
267
+ session.id = real;
268
+ sessions.set(real, session);
269
+ notify("claude/session/rebound", {
270
+ threadId: visibleSession(previous),
271
+ newThreadId: visibleSession(real),
272
+ });
273
+ }
274
+
275
+ const PERMISSION_MODES = ["default", "acceptEdits", "plan", "auto", "bypassPermissions"];
276
+
277
+ function permissionMode(requested, fallback = "default") {
278
+ const mode = String(requested || "");
279
+ return PERMISSION_MODES.includes(mode) ? mode : fallback;
280
+ }
281
+
282
+ // Moves a live session onto a mode the badge picked. A rejected mode — policy
283
+ // disables bypass, say — leaves the session on the one it already had.
284
+ async function applyPermissionMode(session, requested) {
285
+ const mode = permissionMode(requested, session.permissionMode || "default");
286
+ if (mode === session.permissionMode) return;
287
+ try {
288
+ await session.query.setPermissionMode(mode);
289
+ session.permissionMode = mode;
290
+ } catch (error) {
291
+ notify("claude/permissionMode/rejected", {
292
+ threadId: visibleSession(session.id),
293
+ permissionMode: mode,
294
+ message: error?.message || String(error),
295
+ });
296
+ }
297
+ }
298
+
218
299
  function makeOptions(params, sessionId, resume) {
219
300
  const options = {
220
301
  cwd: params.cwd || process.cwd(),
221
302
  includePartialMessages: true,
222
- permissionMode: "default",
303
+ permissionMode: permissionMode(params.permissionMode),
304
+ // Not a mode, a capability: the SDK refuses `bypassPermissions` outright
305
+ // unless the session was started with this. Devez Vibe already auto-allows
306
+ // tools through `canUseTool`, so allowing the mode to be *reachable* grants
307
+ // nothing the session did not already have.
308
+ allowDangerouslySkipPermissions: true,
223
309
  enableFileCheckpointing: true,
224
310
  persistSession: true,
225
311
  settingSources: ["user", "project", "local"],
@@ -284,9 +370,13 @@ async function requestToolPermission(toolName, input, permission) {
284
370
  return { behavior: "allow", updatedInput: { ...input, answers } };
285
371
  }
286
372
 
373
+ // Plan mode only means anything if leaving it is the user's call — the CLI
374
+ // shows the plan and waits. The blanket allow below would answer for them.
375
+ const planApproval = toolName === "ExitPlanMode";
376
+
287
377
  // Devez Vibe runs in its existing full-access profile. Claude's callback is
288
378
  // still kept so AskUserQuestion and explicit user-authored ask rules reach UI.
289
- if (!permission.matchedAskRule) {
379
+ if (!permission.matchedAskRule && !planApproval) {
290
380
  return { behavior: "allow", updatedInput: input };
291
381
  }
292
382
 
@@ -295,7 +385,12 @@ async function requestToolPermission(toolName, input, permission) {
295
385
  reason: permission.decisionReason || permission.description || permission.title,
296
386
  permissions: { tool: toolName, blockedPath: permission.blockedPath },
297
387
  };
298
- if (toolName === "Bash") {
388
+ if (planApproval) {
389
+ params = {
390
+ reason: input.plan || permission.description || "계획대로 진행할까요?",
391
+ permissions: { tool: toolName },
392
+ };
393
+ } else if (toolName === "Bash") {
299
394
  method = "item/commandExecution/requestApproval";
300
395
  params = {
301
396
  command: input.command || "command",
@@ -330,6 +425,7 @@ async function createSession(params, resumeId) {
330
425
  cwd: params.cwd || process.cwd(),
331
426
  model: visibleModel(params.model),
332
427
  effort: params.effort || "",
428
+ permissionMode: permissionMode(params.permissionMode),
333
429
  models: [],
334
430
  queue,
335
431
  query: null,
@@ -341,6 +437,7 @@ async function createSession(params, resumeId) {
341
437
  streamBlocks: new Map(),
342
438
  tools: new Map(),
343
439
  tasks: new Map(),
440
+ subagents: new Map(),
344
441
  lastContextUsage: null,
345
442
  lastContextWindow: 0,
346
443
  };
@@ -433,7 +530,7 @@ function historyTokenUsage(messages, models, model) {
433
530
  }
434
531
  if (!counted) return null;
435
532
  const capabilities = modelCapabilities(models, model);
436
- const contextWindow = Number(capabilities?.contextWindow || capabilities?.contextWindowSize || 0);
533
+ const contextWindow = capabilityContextWindow(capabilities, model);
437
534
  return {
438
535
  total,
439
536
  ...(last ? { last } : {}),
@@ -482,14 +579,20 @@ function processStreamEvent(session, message) {
482
579
  }
483
580
 
484
581
  function processAssistant(session, message) {
485
- if (!session.turn || message.parent_tool_use_id) return;
582
+ if (!session.turn) return;
583
+ if (message.parent_tool_use_id) {
584
+ recordSubagentMessage(session, message);
585
+ return;
586
+ }
486
587
  session.lastContextUsage = tokenBreakdown(message.message?.usage);
487
588
  const capabilities = modelCapabilities(
488
589
  session.models,
489
590
  message.message?.model || session.model,
490
591
  );
491
- session.lastContextWindow = Number(
492
- capabilities?.contextWindow || capabilities?.contextWindowSize || 0,
592
+ session.lastContextWindow = capabilityContextWindow(
593
+ capabilities,
594
+ message.message?.model,
595
+ session.model,
493
596
  );
494
597
  const content = Array.isArray(message.message?.content) ? message.message.content : [];
495
598
  for (const block of content) {
@@ -523,6 +626,7 @@ function processToolUse(session, block) {
523
626
  const item = toolItem(session, block.id, name, input);
524
627
  session.tools.set(block.id, { name, input, item });
525
628
  emitItem(session, "started", item);
629
+ if (SUBAGENT_TOOLS.includes(name)) startSubagent(session, block);
526
630
  }
527
631
 
528
632
  function toolItem(session, id, name, input) {
@@ -643,10 +747,123 @@ function numberedTaskSubject(subject, index) {
643
747
  return /^\d+\.\s/.test(text) ? text : `${index + 1}. ${text}`;
644
748
  }
645
749
 
750
+ // 서브에이전트는 자기 메시지를 부모 Task 툴콜의 `parent_tool_use_id`와 함께 흘려보낸다.
751
+ // 그 ID로 묶어 두면 지금 어떤 에이전트가 무슨 도구를 돌리는지 그대로 복원할 수 있다.
752
+ const SUBAGENT_TOOLS = ["Agent", "Task"];
753
+
754
+ function startSubagent(session, block) {
755
+ const input = block.input || {};
756
+ session.subagents.set(block.id, {
757
+ id: block.id,
758
+ name: firstLine(input.subagent_type || input.agentType || "agent", 40),
759
+ description: firstLine(input.description || input.prompt || "", 120),
760
+ tool: "",
761
+ startedAt: Date.now(),
762
+ });
763
+ emitSubagents(session);
764
+ }
765
+
766
+ // 서브에이전트가 실제로 무엇을 했는지는 자식 메시지에만 남는다. 열람용 기록은 여기서
767
+ // 한 줄씩 흘려보내고, 목록 행에 쓸 현재 도구만 따로 갱신한다.
768
+ function recordSubagentMessage(session, message) {
769
+ const running = session.subagents.get(message.parent_tool_use_id);
770
+ if (!running) return;
771
+ const content = Array.isArray(message.message?.content) ? message.message.content : [];
772
+ let toolChanged = false;
773
+ for (const block of content) {
774
+ if (block.type === "text") {
775
+ const text = String(block.text || "").trim();
776
+ if (text) emitSubagentLine(session, running.id, { kind: "text", text });
777
+ } else if (block.type === "tool_use") {
778
+ running.tool = subagentToolLabel(block);
779
+ toolChanged = true;
780
+ emitSubagentLine(session, running.id, {
781
+ kind: "tool",
782
+ text: running.tool,
783
+ toolUseId: block.id,
784
+ });
785
+ }
786
+ }
787
+ if (toolChanged) emitSubagents(session);
788
+ }
789
+
790
+ function recordSubagentResult(session, message) {
791
+ const running = session.subagents.get(message.parent_tool_use_id);
792
+ if (!running) return;
793
+ const content = Array.isArray(message.message?.content) ? message.message.content : [];
794
+ for (const block of content) {
795
+ if (block.type !== "tool_result") continue;
796
+ emitSubagentLine(session, running.id, {
797
+ kind: block.is_error ? "error" : "result",
798
+ text: firstLine(toolOutput(block.content, message.tool_use_result), 200),
799
+ toolUseId: block.tool_use_id,
800
+ });
801
+ }
802
+ }
803
+
804
+ function emitSubagentLine(session, parentToolUseId, line) {
805
+ notify("turn/subagent/line", {
806
+ threadId: session.id,
807
+ turnId: session.turn?.id,
808
+ parentToolUseId,
809
+ line,
810
+ });
811
+ }
812
+
813
+ function subagentToolLabel(block) {
814
+ const name = block.name || "Tool";
815
+ const input = block.input || {};
816
+ const detail = input.command
817
+ ?? input.pattern
818
+ ?? input.file_path
819
+ ?? input.description
820
+ ?? input.query
821
+ ?? input.url
822
+ ?? "";
823
+ const text = firstLine(detail, 60);
824
+ return text ? `${name}(${text})` : name;
825
+ }
826
+
827
+ function finishSubagent(session, toolUseId) {
828
+ if (!session.subagents.delete(toolUseId)) return;
829
+ emitSubagents(session);
830
+ }
831
+
832
+ function clearSubagents(session) {
833
+ if (!session.subagents.size) return;
834
+ session.subagents.clear();
835
+ emitSubagents(session);
836
+ }
837
+
838
+ function firstLine(value, limit) {
839
+ return String(value ?? "").split("\n")[0].trim().slice(0, limit);
840
+ }
841
+
842
+ function emitSubagents(session) {
843
+ notify("turn/subagents/updated", {
844
+ threadId: session.id,
845
+ turnId: session.turn?.id,
846
+ subagents: [...session.subagents.values()].map((agent) => ({
847
+ id: agent.id,
848
+ name: agent.name,
849
+ description: agent.description,
850
+ tool: agent.tool,
851
+ elapsedMs: Date.now() - agent.startedAt,
852
+ })),
853
+ });
854
+ }
855
+
646
856
  function processUser(session, message) {
857
+ // 자식 tool_result의 tool_use_id는 부모 세션의 것과 다른 공간이므로, 부모 흐름에
858
+ // 섞이기 전에 서브에이전트 기록으로 보낸다.
859
+ if (message.parent_tool_use_id) {
860
+ recordSubagentResult(session, message);
861
+ return;
862
+ }
647
863
  const content = Array.isArray(message.message?.content) ? message.message.content : [];
648
864
  for (const block of content) {
649
865
  if (block.type !== "tool_result") continue;
866
+ finishSubagent(session, block.tool_use_id);
650
867
  const pending = session.tools.get(block.tool_use_id);
651
868
  if (!pending) continue;
652
869
  pending.toolUseId = block.tool_use_id;
@@ -700,7 +917,9 @@ async function processResult(session, message) {
700
917
  tokenUsage: {
701
918
  total: totals,
702
919
  ...(session.lastContextUsage ? { last: session.lastContextUsage } : {}),
703
- modelContextWindow: session.lastContextWindow || totals.contextWindow || undefined,
920
+ // `modelUsage` reports the window the turn actually ran under, so it wins
921
+ // over the size guessed from the model name.
922
+ modelContextWindow: totals.contextWindow || session.lastContextWindow || undefined,
704
923
  },
705
924
  });
706
925
  const error = message.is_error && !interrupted
@@ -735,6 +954,7 @@ async function runPendingPrompt(session) {
735
954
 
736
955
  function finishTurn(session, error, durationMs) {
737
956
  if (!session.turn) return;
957
+ clearSubagents(session);
738
958
  const turn = { id: session.turn.id, status: error ? "failed" : "completed" };
739
959
  if (error) turn.error = { message: error instanceof Error ? error.message : error.message || String(error) };
740
960
  if (durationMs != null) turn.durationMs = durationMs;
@@ -745,6 +965,7 @@ function finishTurn(session, error, durationMs) {
745
965
 
746
966
  async function consume(session) {
747
967
  for await (const message of session.query) {
968
+ adoptSessionId(session, message.session_id);
748
969
  if (message.type === "stream_event") {
749
970
  if (message.event?.type === "content_block_delta" && (message.event?.delta?.text || message.event?.delta?.thinking)) {
750
971
  if (session.turn) session.turn.sawStreamText = true;
@@ -807,7 +1028,7 @@ async function inputContent(input, handoffContext) {
807
1028
  }
808
1029
 
809
1030
  async function startPrompt(params) {
810
- const id = rawSession(params.sessionId);
1031
+ const id = liveSessionId(params.sessionId);
811
1032
  const session = sessions.get(id);
812
1033
  if (!session) throw new Error(`Claude 세션을 찾을 수 없습니다: ${id}`);
813
1034
  // Claude runs one turn at a time, so extra input waits its turn instead of
@@ -831,6 +1052,7 @@ async function runPrompt(session, params) {
831
1052
  await session.query.applyFlagSettings({ effortLevel: effort });
832
1053
  }
833
1054
  session.effort = effort;
1055
+ await applyPermissionMode(session, params.permissionMode);
834
1056
  const turnId = `claude-turn-${session.turnSequence++}-${randomUUID()}`;
835
1057
  session.turn = { id: turnId, sawStreamText: false };
836
1058
  session.lastContextUsage = null;
@@ -953,6 +1175,13 @@ function historyTurns(messages) {
953
1175
 
954
1176
  async function dispatch(method, params = {}) {
955
1177
  if (method === "model/list") return loadModelCatalog(params);
1178
+ if (method === "session/permissionMode") {
1179
+ const session = lookupSession(params.sessionId);
1180
+ // A session that has not started yet picks the mode up from its first turn.
1181
+ if (!session) return { permissionMode: permissionMode(params.permissionMode) };
1182
+ await applyPermissionMode(session, params.permissionMode);
1183
+ return { permissionMode: session.permissionMode };
1184
+ }
956
1185
  if (method === "session/start") {
957
1186
  const { session, account, usage } = await createSession(params);
958
1187
  return {
@@ -966,7 +1195,7 @@ async function dispatch(method, params = {}) {
966
1195
  };
967
1196
  }
968
1197
  if (method === "session/resume") {
969
- const id = rawSession(params.sessionId);
1198
+ const id = liveSessionId(params.sessionId);
970
1199
  const existing = sessions.get(id);
971
1200
  if (existing) {
972
1201
  const messages = await getSessionMessages(id, { dir: existing.cwd, includeSystemMessages: true });
@@ -1022,13 +1251,13 @@ async function dispatch(method, params = {}) {
1022
1251
  };
1023
1252
  }
1024
1253
  if (method === "session/history") {
1025
- const id = rawSession(params.sessionId);
1254
+ const id = liveSessionId(params.sessionId);
1026
1255
  const messages = await getSessionMessages(id, { dir: params.cwd, includeSystemMessages: true });
1027
1256
  return { data: historyTurns(messages), nextCursor: null };
1028
1257
  }
1029
1258
  if (method === "session/prompt") return startPrompt(params);
1030
1259
  if (method === "session/interrupt") {
1031
- const session = sessions.get(rawSession(params.sessionId));
1260
+ const session = lookupSession(params.sessionId);
1032
1261
  // Stopping the run drops what was waiting behind it too, so nothing the user
1033
1262
  // just cancelled starts on its own afterwards.
1034
1263
  if (session) session.pendingPrompts.length = 0;
@@ -1048,7 +1277,7 @@ async function dispatch(method, params = {}) {
1048
1277
  return startPrompt({ ...params, input: [{ type: "text", text: "/compact" }] });
1049
1278
  }
1050
1279
  if (method === "session/fork") {
1051
- const source = rawSession(params.sessionId);
1280
+ const source = liveSessionId(params.sessionId);
1052
1281
  const forked = await forkSession(source, { dir: params.cwd });
1053
1282
  const id = forked.sessionId || forked;
1054
1283
  const { session, account, usage } = await createSession(params, id);
@@ -1063,17 +1292,20 @@ async function dispatch(method, params = {}) {
1063
1292
  };
1064
1293
  }
1065
1294
  if (method === "session/close") {
1066
- const session = sessions.get(rawSession(params.sessionId));
1295
+ const session = lookupSession(params.sessionId);
1067
1296
  if (session) {
1068
1297
  session.queue.close();
1069
1298
  session.query.close();
1070
1299
  sessions.delete(session.id);
1300
+ for (const [from, to] of sessionAliases) {
1301
+ if (to === session.id) sessionAliases.delete(from);
1302
+ }
1071
1303
  if (params.delete) await deleteSession(session.id, { dir: session.cwd });
1072
1304
  }
1073
1305
  return {};
1074
1306
  }
1075
1307
  if (method === "account/usage") {
1076
- const session = sessions.get(rawSession(params.sessionId)) || [...sessions.values()][0];
1308
+ const session = lookupSession(params.sessionId) || [...sessions.values()][0];
1077
1309
  if (!session) return { account: null, usage: null };
1078
1310
  return { account: await safeAccount(session.query), usage: await safeUsage(session.query) };
1079
1311
  }
@@ -1083,6 +1315,7 @@ async function dispatch(method, params = {}) {
1083
1315
  session.query.close();
1084
1316
  }
1085
1317
  sessions.clear();
1318
+ sessionAliases.clear();
1086
1319
  return {};
1087
1320
  }
1088
1321
  throw new Error(`지원하지 않는 Claude 브리지 메서드: ${method}`);
@@ -1133,6 +1366,14 @@ function runSelfTest() {
1133
1366
  if (usage.totalTokens !== 68_802 || usage.inputTokens !== 68_502) {
1134
1367
  throw new Error(`Claude usage self-test failed: ${JSON.stringify(usage)}`);
1135
1368
  }
1369
+ const windows = [
1370
+ catalogEntry({ value: "opus[1m]", resolvedModel: "claude-opus-5[1m]" }, "").contextWindow,
1371
+ catalogEntry({ value: "sonnet", resolvedModel: "claude-sonnet-5" }, "").contextWindow,
1372
+ catalogEntry({ value: "haiku", resolvedModel: "x", contextWindow: 300_000 }, "").contextWindow,
1373
+ ];
1374
+ if (windows.join(",") !== "1000000,200000,300000") {
1375
+ throw new Error(`Claude context window self-test failed: ${windows.join(",")}`);
1376
+ }
1136
1377
  process.stdout.write("Claude bridge self-test passed\n");
1137
1378
  }
1138
1379
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "devez-vibe",
3
- "version": "0.1.48",
3
+ "version": "1.2.1",
4
4
  "description": "Stable terminal UI for Codex and Claude Agent SDK",
5
5
  "keywords": [
6
6
  "codex",