gemcode 0.3.29__tar.gz → 0.3.30__tar.gz

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 (116) hide show
  1. {gemcode-0.3.29/src/gemcode.egg-info → gemcode-0.3.30}/PKG-INFO +1 -1
  2. {gemcode-0.3.29 → gemcode-0.3.30}/pyproject.toml +1 -1
  3. {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/agent.py +39 -0
  4. {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/callbacks.py +68 -0
  5. {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/config.py +15 -0
  6. gemcode-0.3.30/src/gemcode/hooks.py +194 -0
  7. {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/modality_tools.py +15 -1
  8. gemcode-0.3.30/src/gemcode/pricing.py +76 -0
  9. {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/repl_commands.py +28 -1
  10. {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/repl_slash.py +42 -2
  11. {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/tui/input_handler.py +19 -1
  12. {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/tui/scrollback.py +44 -1
  13. {gemcode-0.3.29 → gemcode-0.3.30/src/gemcode.egg-info}/PKG-INFO +1 -1
  14. {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode.egg-info/SOURCES.txt +2 -0
  15. {gemcode-0.3.29 → gemcode-0.3.30}/LICENSE +0 -0
  16. {gemcode-0.3.29 → gemcode-0.3.30}/MANIFEST.in +0 -0
  17. {gemcode-0.3.29 → gemcode-0.3.30}/README.md +0 -0
  18. {gemcode-0.3.29 → gemcode-0.3.30}/setup.cfg +0 -0
  19. {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/__init__.py +0 -0
  20. {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/__main__.py +0 -0
  21. {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/audit.py +0 -0
  22. {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/autocompact.py +0 -0
  23. {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/capability_routing.py +0 -0
  24. {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/cli.py +0 -0
  25. {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/compaction.py +0 -0
  26. {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/computer_use/__init__.py +0 -0
  27. {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/computer_use/browser_computer.py +0 -0
  28. {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/context_budget.py +0 -0
  29. {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/context_warning.py +0 -0
  30. {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/credentials.py +0 -0
  31. {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/hitl_session.py +0 -0
  32. {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/interactions.py +0 -0
  33. {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/invoke.py +0 -0
  34. {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/kairos_daemon.py +0 -0
  35. {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/limits.py +0 -0
  36. {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/live_audio_engine.py +0 -0
  37. {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/logging_config.py +0 -0
  38. {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/mcp_loader.py +0 -0
  39. {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/memory/__init__.py +0 -0
  40. {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/memory/embedding_memory_service.py +0 -0
  41. {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/memory/file_memory_service.py +0 -0
  42. {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/model_errors.py +0 -0
  43. {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/model_routing.py +0 -0
  44. {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/paths.py +0 -0
  45. {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/permissions.py +0 -0
  46. {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/plugins/__init__.py +0 -0
  47. {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/plugins/terminal_hooks_plugin.py +0 -0
  48. {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/plugins/tool_recovery_plugin.py +0 -0
  49. {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/prompt_suggestions.py +0 -0
  50. {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/query/__init__.py +0 -0
  51. {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/query/config.py +0 -0
  52. {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/query/deps.py +0 -0
  53. {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/query/engine.py +0 -0
  54. {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/query/stop_hooks.py +0 -0
  55. {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/query/token_budget.py +0 -0
  56. {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/query/transitions.py +0 -0
  57. {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/refine.py +0 -0
  58. {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/session_runtime.py +0 -0
  59. {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/slash_commands.py +0 -0
  60. {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/thinking.py +0 -0
  61. {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/tool_prompt_manifest.py +0 -0
  62. {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/tool_registry.py +0 -0
  63. {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/tools/__init__.py +0 -0
  64. {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/tools/bash.py +0 -0
  65. {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/tools/browser.py +0 -0
  66. {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/tools/edit.py +0 -0
  67. {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/tools/filesystem.py +0 -0
  68. {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/tools/search.py +0 -0
  69. {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/tools/shell.py +0 -0
  70. {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/tools/shell_gate.py +0 -0
  71. {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/tools/subtask.py +0 -0
  72. {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/tools/think.py +0 -0
  73. {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/tools/todo.py +0 -0
  74. {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/tools/web.py +0 -0
  75. {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/tools_inspector.py +0 -0
  76. {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/trust.py +0 -0
  77. {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/tui/spinner.py +0 -0
  78. {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/tui/welcome_banner.py +0 -0
  79. {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/tui/welcome_rich.py +0 -0
  80. {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/version.py +0 -0
  81. {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/vertex.py +0 -0
  82. {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/web/__init__.py +0 -0
  83. {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/web/claude_sse_adapter.py +0 -0
  84. {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/web/terminal_repl.py +0 -0
  85. {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode/workspace_hints.py +0 -0
  86. {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode.egg-info/dependency_links.txt +0 -0
  87. {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode.egg-info/entry_points.txt +0 -0
  88. {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode.egg-info/requires.txt +0 -0
  89. {gemcode-0.3.29 → gemcode-0.3.30}/src/gemcode.egg-info/top_level.txt +0 -0
  90. {gemcode-0.3.29 → gemcode-0.3.30}/tests/test_agent_instruction.py +0 -0
  91. {gemcode-0.3.29 → gemcode-0.3.30}/tests/test_autocompact.py +0 -0
  92. {gemcode-0.3.29 → gemcode-0.3.30}/tests/test_capability_routing.py +0 -0
  93. {gemcode-0.3.29 → gemcode-0.3.30}/tests/test_claude_web_adapter_sse.py +0 -0
  94. {gemcode-0.3.29 → gemcode-0.3.30}/tests/test_cli_init.py +0 -0
  95. {gemcode-0.3.29 → gemcode-0.3.30}/tests/test_computer_use_permissions.py +0 -0
  96. {gemcode-0.3.29 → gemcode-0.3.30}/tests/test_context_budget.py +0 -0
  97. {gemcode-0.3.29 → gemcode-0.3.30}/tests/test_context_warning.py +0 -0
  98. {gemcode-0.3.29 → gemcode-0.3.30}/tests/test_credentials.py +0 -0
  99. {gemcode-0.3.29 → gemcode-0.3.30}/tests/test_interactive_permission_ask.py +0 -0
  100. {gemcode-0.3.29 → gemcode-0.3.30}/tests/test_kairos_scheduler.py +0 -0
  101. {gemcode-0.3.29 → gemcode-0.3.30}/tests/test_modality_tools.py +0 -0
  102. {gemcode-0.3.29 → gemcode-0.3.30}/tests/test_model_error_retry.py +0 -0
  103. {gemcode-0.3.29 → gemcode-0.3.30}/tests/test_model_errors.py +0 -0
  104. {gemcode-0.3.29 → gemcode-0.3.30}/tests/test_model_routing.py +0 -0
  105. {gemcode-0.3.29 → gemcode-0.3.30}/tests/test_paths.py +0 -0
  106. {gemcode-0.3.29 → gemcode-0.3.30}/tests/test_permissions.py +0 -0
  107. {gemcode-0.3.29 → gemcode-0.3.30}/tests/test_prompt_suggestions.py +0 -0
  108. {gemcode-0.3.29 → gemcode-0.3.30}/tests/test_repl_commands.py +0 -0
  109. {gemcode-0.3.29 → gemcode-0.3.30}/tests/test_repl_slash.py +0 -0
  110. {gemcode-0.3.29 → gemcode-0.3.30}/tests/test_slash_commands.py +0 -0
  111. {gemcode-0.3.29 → gemcode-0.3.30}/tests/test_thinking_config.py +0 -0
  112. {gemcode-0.3.29 → gemcode-0.3.30}/tests/test_token_budget.py +0 -0
  113. {gemcode-0.3.29 → gemcode-0.3.30}/tests/test_tool_context_circulation.py +0 -0
  114. {gemcode-0.3.29 → gemcode-0.3.30}/tests/test_tools.py +0 -0
  115. {gemcode-0.3.29 → gemcode-0.3.30}/tests/test_tools_inspector.py +0 -0
  116. {gemcode-0.3.29 → gemcode-0.3.30}/tests/test_workspace_hints.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gemcode
3
- Version: 0.3.29
3
+ Version: 0.3.30
4
4
  Summary: Local-first coding agent on Google Gemini + ADK
5
5
  Author: GemCode Contributors
6
6
  License: Apache License
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "gemcode"
7
- version = "0.3.29"
7
+ version = "0.3.30"
8
8
  description = "Local-first coding agent on Google Gemini + ADK"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -66,6 +66,8 @@ def _build_runtime_facts(cfg: GemCodeConfig) -> str:
66
66
 
67
67
  # ── Active capabilities ──────────────────────────────────────────────────
68
68
  caps: list[str] = []
69
+ if getattr(cfg, "enable_web_search", False) and not getattr(cfg, "enable_deep_research", False):
70
+ caps.append("web_search ON (tool: google_search — standalone search without full deep_research)")
69
71
  if getattr(cfg, "enable_deep_research", False):
70
72
  dr_extras = " + google_maps_grounding" if getattr(cfg, "enable_maps_grounding", False) else ""
71
73
  caps.append(f"deep_research ON (tools: google_search, url_context{dr_extras})")
@@ -151,6 +153,40 @@ Memory is **ON** ({kind}). Stored at: `{mem_path}`
151
153
  """
152
154
 
153
155
 
156
+ def _build_plan_mode_section() -> str:
157
+ """Injected when plan_mode=True — instructs agent to write explicit plans first."""
158
+ return """
159
+ ## PLAN MODE IS ACTIVE
160
+
161
+ You are currently in **Plan Mode**. Before executing ANY tools that modify files or run shell commands, you MUST:
162
+
163
+ 1. **Write a numbered plan** in your response text — list every step you intend to take.
164
+ Example:
165
+ ```
166
+ Plan:
167
+ 1. Read src/auth/login.ts to understand the current flow
168
+ 2. Read src/types/user.ts for the User interface
169
+ 3. Add `lastLogin: Date` field to User interface
170
+ 4. Update login handler to set lastLogin on successful auth
171
+ 5. Run `npm run build` to verify no TypeScript errors
172
+ ```
173
+
174
+ 2. **Pause after the plan** — do not immediately execute tools. Present the plan and wait for the user to confirm ("go", "proceed", "looks good") before starting.
175
+
176
+ 3. **Stick to the plan** — if you discover the plan needs changing mid-execution, note the update before proceeding.
177
+
178
+ 4. **Report completion** against the plan — when done, confirm each step as completed.
179
+
180
+ **Why plan mode?**
181
+ - Prevents unintended side effects from premature tool execution
182
+ - Gives you and the user visibility into the full scope before any changes
183
+ - Makes complex multi-file tasks reviewable and reversible
184
+ - Catches scope creep early
185
+
186
+ **To turn off plan mode**, type `/plan off` at the prompt.
187
+ """
188
+
189
+
154
190
  def _build_computer_use_section(cfg: GemCodeConfig) -> str:
155
191
  """Rich computer use guidance, only injected when enable_computer_use=True."""
156
192
  w = getattr(cfg, "_cfg", None)
@@ -500,6 +536,9 @@ All file tools use paths **relative to the project root** (where GemCode was sta
500
536
  if getattr(cfg, "enable_memory", False):
501
537
  base = f"{base}\n\n{_build_memory_section(cfg)}"
502
538
 
539
+ if getattr(cfg, "plan_mode", False):
540
+ base = f"{base}\n\n{_build_plan_mode_section()}"
541
+
503
542
  tool_manifest = build_tool_manifest(cfg)
504
543
  if tool_manifest:
505
544
  base = f"{base}\n\n{tool_manifest}"
@@ -141,6 +141,22 @@ def make_before_tool_callback(cfg: GemCodeConfig):
141
141
  record = {"tool": name, "args": _redact_args(name, args)}
142
142
  append_audit(cfg.project_root, record)
143
143
 
144
+ # ── Shell hooks: pre_tool_use ─────────────────────────────────────────
145
+ # If the project has a .gemcode/hooks/pre_tool_use.sh, run it now.
146
+ # Non-zero exit or {"decision":"deny"} stdout will block the tool call.
147
+ try:
148
+ from gemcode.hooks import run_pre_tool_use_hook
149
+ hook_result = run_pre_tool_use_hook(
150
+ cfg.project_root,
151
+ model=getattr(cfg, "model", "") or "",
152
+ tool_name=name,
153
+ args=args or {},
154
+ )
155
+ if hook_result is not None:
156
+ return hook_result
157
+ except Exception:
158
+ pass
159
+
144
160
  streak = 0
145
161
  if tool_context is not None:
146
162
  try:
@@ -316,6 +332,19 @@ def make_after_tool_callback(cfg: GemCodeConfig):
316
332
  st[_STATE_FAILURE_KEY] = 0
317
333
  else:
318
334
  st[_STATE_FAILURE_KEY] = 0
335
+ # ── Shell hooks: post_tool_use ────────────────────────────────────────
336
+ try:
337
+ from gemcode.hooks import run_post_tool_use_hook
338
+ run_post_tool_use_hook(
339
+ cfg.project_root,
340
+ model=getattr(cfg, "model", "") or "",
341
+ tool_name=name,
342
+ args=args or {},
343
+ result=tool_response if isinstance(tool_response, dict) else {},
344
+ )
345
+ except Exception:
346
+ pass
347
+
319
348
  if _maybe_tool_summary_enabled():
320
349
  summary: dict[str, Any] = {
321
350
  "phase": "tool_result",
@@ -399,6 +428,7 @@ def make_after_model_callback(cfg: GemCodeConfig):
399
428
  "candidates_token_count",
400
429
  "cached_content_token_count",
401
430
  "total_token_count",
431
+ "thoughts_token_count",
402
432
  ):
403
433
  if hasattr(um, attr):
404
434
  v = getattr(um, attr)
@@ -407,6 +437,44 @@ def make_after_model_callback(cfg: GemCodeConfig):
407
437
  if d:
408
438
  append_audit(cfg.project_root, {"phase": "model_usage", **d})
409
439
 
440
+ # ── Expose live token stats to the TUI ───────────────────────────────────
441
+ # The TUI reads cfg._last_turn_stats after each turn to display token counts
442
+ # and estimated cost in the footer (like OpenClaude's spinner token display).
443
+ try:
444
+ in_tok = d.get("prompt_token_count", 0) or 0
445
+ out_tok = d.get("candidates_token_count", 0) or 0
446
+ think_tok = d.get("thoughts_token_count", 0) or 0
447
+ cache_tok = d.get("cached_content_token_count", 0) or 0
448
+ total_tok = d.get("total_token_count", 0) or 0
449
+
450
+ prev_session_tokens = int(st.get(SESSION_TOTAL_TOKENS_KEY, 0) or 0)
451
+ session_total = prev_session_tokens + total_tok
452
+
453
+ from gemcode.pricing import estimate_cost
454
+ turn_cost = estimate_cost(
455
+ getattr(cfg, "model", "") or "",
456
+ input_tokens=in_tok,
457
+ output_tokens=out_tok,
458
+ )
459
+ # Accumulate session cost
460
+ prev_cost = getattr(cfg, "_session_cost_usd", 0.0) or 0.0
461
+ session_cost = prev_cost + (turn_cost or 0.0)
462
+ object.__setattr__(cfg, "_session_cost_usd", session_cost)
463
+
464
+ stats: dict[str, Any] = {
465
+ "in": in_tok,
466
+ "out": out_tok,
467
+ "think": think_tok,
468
+ "cache": cache_tok,
469
+ "total": total_tok,
470
+ "session_total": session_total,
471
+ "turn_cost": turn_cost,
472
+ "session_cost": session_cost,
473
+ }
474
+ object.__setattr__(cfg, "_last_turn_stats", stats)
475
+ except Exception:
476
+ pass
477
+
410
478
  pt = d.get("prompt_token_count")
411
479
  if isinstance(pt, int) and pt >= 0:
412
480
  try:
@@ -267,6 +267,21 @@ class GemCodeConfig:
267
267
  default_factory=lambda: _truthy_env("GEMCODE_ENABLE_ARTIFACTS", default=True)
268
268
  )
269
269
 
270
+ # Plan mode: when ON, the agent explicitly writes out a numbered plan
271
+ # BEFORE executing any tools, then checks the plan before reporting done.
272
+ # Like OpenClaude's EnterPlanMode — great for complex, multi-file tasks.
273
+ # Toggle at runtime with /plan on|off.
274
+ plan_mode: bool = field(
275
+ default_factory=lambda: _truthy_env("GEMCODE_PLAN_MODE", default=False)
276
+ )
277
+
278
+ # Always-on web search (independent of deep_research / research mode).
279
+ # When True, google_search is available as a basic tool without enabling
280
+ # the full deep_research capability suite (url_context, maps, etc.).
281
+ enable_web_search: bool = field(
282
+ default_factory=lambda: _truthy_env("GEMCODE_ENABLE_WEB_SEARCH", default=False)
283
+ )
284
+
270
285
  def __post_init__(self) -> None:
271
286
  self.project_root = self.project_root.resolve()
272
287
  # Default agentic depth when env omits GEMCODE_MAX_LLM_CALLS (was: None → SDK default).
@@ -0,0 +1,194 @@
1
+ """
2
+ GemCode shell hooks — scriptable lifecycle events inspired by OpenClaude.
3
+
4
+ Hooks are shell scripts stored under ``<project_root>/.gemcode/hooks/``.
5
+ They receive a JSON payload on stdin and can:
6
+ - pre_tool_use.sh: return non-zero exit to DENY the tool call, or
7
+ print JSON {"decision":"deny","reason":"..."} to stderr.
8
+ - post_tool_use.sh: informational; return value ignored.
9
+ - session_start.sh: runs when a new GemCode session starts.
10
+ - session_stop.sh: runs when the session ends.
11
+
12
+ HOOK ENVIRONMENT
13
+ All hooks receive these env vars:
14
+ GEMCODE_HOOK_TYPE — "pre_tool_use" | "post_tool_use" | "session_start" | "session_stop"
15
+ GEMCODE_PROJECT_ROOT — absolute path to the project root
16
+ GEMCODE_MODEL — active model id
17
+
18
+ HOOK STDIN (pre_tool_use, post_tool_use)
19
+ JSON object with:
20
+ { "tool": "<tool_name>",
21
+ "args": { ...tool_args... },
22
+ "type": "pre_tool_use" | "post_tool_use",
23
+ "result": { ...tool_result... } // post only
24
+ }
25
+
26
+ PRE_TOOL_USE DECISION
27
+ If exit code is non-zero → the tool is DENIED.
28
+ If exit code is 0 → the tool proceeds normally.
29
+ If stdout starts with '{' and contains "decision": "deny" → the tool is denied.
30
+
31
+ EXAMPLE HOOKS
32
+ # .gemcode/hooks/pre_tool_use.sh
33
+ #!/bin/bash
34
+ # Deny all delete_file calls
35
+ HOOK=$(cat)
36
+ TOOL=$(echo "$HOOK" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['tool'])")
37
+ if [ "$TOOL" = "delete_file" ]; then
38
+ echo "delete_file blocked by hook" >&2
39
+ exit 1
40
+ fi
41
+
42
+ # .gemcode/hooks/session_start.sh
43
+ #!/bin/bash
44
+ echo "[gemcode hook] session started in $GEMCODE_PROJECT_ROOT" >> /tmp/gemcode-audit.log
45
+ """
46
+
47
+ from __future__ import annotations
48
+
49
+ import json
50
+ import logging
51
+ import os
52
+ import subprocess
53
+ from pathlib import Path
54
+ from typing import Any
55
+
56
+ log = logging.getLogger(__name__)
57
+
58
+ _HOOKS_DIR = ".gemcode/hooks"
59
+ _TIMEOUT_PRE = 5.0 # seconds — pre_tool_use must respond quickly
60
+ _TIMEOUT_POST = 10.0 # seconds — post_tool_use has more budget
61
+
62
+
63
+ def _find_hook(project_root: Path, name: str) -> Path | None:
64
+ """Return the hook file path if it exists and is executable, else None."""
65
+ hooks_dir = project_root / _HOOKS_DIR
66
+ for ext in ("", ".sh", ".py", ".bash"):
67
+ candidate = hooks_dir / f"{name}{ext}"
68
+ if candidate.is_file() and os.access(candidate, os.X_OK):
69
+ return candidate
70
+ return None
71
+
72
+
73
+ def _run_hook(
74
+ hook_path: Path,
75
+ project_root: Path,
76
+ model: str,
77
+ hook_type: str,
78
+ stdin_data: dict | None = None,
79
+ timeout: float = 5.0,
80
+ ) -> tuple[int, str, str]:
81
+ """
82
+ Run a hook script.
83
+
84
+ Returns (exit_code, stdout, stderr).
85
+ Never raises — all exceptions are caught and logged.
86
+ """
87
+ try:
88
+ env = {
89
+ **os.environ,
90
+ "GEMCODE_HOOK_TYPE": hook_type,
91
+ "GEMCODE_PROJECT_ROOT": str(project_root),
92
+ "GEMCODE_MODEL": model or "",
93
+ }
94
+ stdin_bytes = (
95
+ json.dumps(stdin_data, ensure_ascii=False).encode() if stdin_data else b""
96
+ )
97
+ result = subprocess.run(
98
+ [str(hook_path)],
99
+ input=stdin_bytes,
100
+ capture_output=True,
101
+ timeout=timeout,
102
+ env=env,
103
+ cwd=str(project_root),
104
+ )
105
+ return result.returncode, result.stdout.decode(errors="replace"), result.stderr.decode(errors="replace")
106
+ except subprocess.TimeoutExpired:
107
+ log.warning("[hooks] %s timed out after %.1fs", hook_path.name, timeout)
108
+ return 1, "", f"hook timed out after {timeout}s"
109
+ except Exception as exc:
110
+ log.debug("[hooks] failed to run %s: %s", hook_path.name, exc)
111
+ return 1, "", str(exc)
112
+
113
+
114
+ def run_pre_tool_use_hook(
115
+ project_root: Path,
116
+ model: str,
117
+ tool_name: str,
118
+ args: dict[str, Any],
119
+ ) -> dict | None:
120
+ """
121
+ Run the pre_tool_use hook (if it exists).
122
+
123
+ Returns:
124
+ None → tool is allowed (default)
125
+ dict → {"error": "...", "error_kind": "hook_denied"} to deny the tool
126
+ """
127
+ hook = _find_hook(project_root, "pre_tool_use")
128
+ if hook is None:
129
+ return None
130
+
131
+ payload = {"tool": tool_name, "args": args, "type": "pre_tool_use"}
132
+ rc, stdout, stderr = _run_hook(
133
+ hook, project_root, model, "pre_tool_use", payload, timeout=_TIMEOUT_PRE
134
+ )
135
+
136
+ if rc != 0:
137
+ reason = stderr.strip() or stdout.strip() or f"hook exited {rc}"
138
+ log.info("[hooks] pre_tool_use denied %s: %s", tool_name, reason[:200])
139
+ return {
140
+ "error": f"Tool denied by pre_tool_use hook: {reason[:400]}",
141
+ "error_kind": "hook_denied",
142
+ }
143
+
144
+ # Check if stdout contains an explicit deny decision.
145
+ if stdout.strip().startswith("{"):
146
+ try:
147
+ decision = json.loads(stdout.strip())
148
+ if str(decision.get("decision", "")).lower() == "deny":
149
+ reason = decision.get("reason", "hook denied")
150
+ log.info("[hooks] pre_tool_use JSON-denied %s: %s", tool_name, reason)
151
+ return {
152
+ "error": f"Tool denied by hook: {reason}",
153
+ "error_kind": "hook_denied",
154
+ }
155
+ except Exception:
156
+ pass
157
+
158
+ return None # allowed
159
+
160
+
161
+ def run_post_tool_use_hook(
162
+ project_root: Path,
163
+ model: str,
164
+ tool_name: str,
165
+ args: dict[str, Any],
166
+ result: dict[str, Any],
167
+ ) -> None:
168
+ """Run the post_tool_use hook if it exists. Return value is ignored."""
169
+ hook = _find_hook(project_root, "post_tool_use")
170
+ if hook is None:
171
+ return
172
+ payload = {
173
+ "tool": tool_name,
174
+ "args": args,
175
+ "result": result,
176
+ "type": "post_tool_use",
177
+ }
178
+ _run_hook(hook, project_root, model, "post_tool_use", payload, timeout=_TIMEOUT_POST)
179
+
180
+
181
+ def run_session_start_hook(project_root: Path, model: str) -> None:
182
+ """Run the session_start hook if it exists."""
183
+ hook = _find_hook(project_root, "session_start")
184
+ if hook is None:
185
+ return
186
+ _run_hook(hook, project_root, model, "session_start", timeout=10.0)
187
+
188
+
189
+ def run_session_stop_hook(project_root: Path, model: str) -> None:
190
+ """Run the session_stop hook if it exists."""
191
+ hook = _find_hook(project_root, "session_stop")
192
+ if hook is None:
193
+ return
194
+ _run_hook(hook, project_root, model, "session_stop", timeout=10.0)
@@ -172,9 +172,23 @@ def build_extra_tools(cfg: GemCodeConfig) -> list[Any]:
172
172
  """Return ADK tool unions to expose for enabled modalities."""
173
173
  extra: list[Any] = []
174
174
 
175
+ # ── Web search (standalone, no full deep_research needed) ────────────────
176
+ # enable_web_search=True adds google_search alone.
177
+ # enable_deep_research=True adds google_search + url_context + optional maps.
178
+ # If both are on, avoid adding google_search twice.
179
+ web_search_added = False
180
+ if getattr(cfg, "enable_web_search", False) and not getattr(cfg, "enable_deep_research", False):
181
+ try:
182
+ from google.adk.tools import google_search
183
+ extra.append(google_search)
184
+ web_search_added = True
185
+ except Exception:
186
+ pass
187
+
175
188
  if getattr(cfg, "enable_deep_research", False):
176
189
  from google.adk.tools import google_search, url_context
177
- extra.append(google_search)
190
+ if not web_search_added:
191
+ extra.append(google_search)
178
192
  extra.append(url_context)
179
193
  # Google Maps grounding can be incompatible with other built-in tools
180
194
  # (e.g., google_search) depending on the request/model tooling layer.
@@ -0,0 +1,76 @@
1
+ """
2
+ Per-model token pricing for cost estimation.
3
+
4
+ All prices are USD per 1 million tokens (input / output separately).
5
+ Sources: Google AI Studio / Vertex AI pricing pages as of 2026.
6
+
7
+ Usage:
8
+ from gemcode.pricing import estimate_cost
9
+ usd = estimate_cost("gemini-2.5-flash", input_tokens=5000, output_tokens=1200)
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ # Format: model_prefix → (input_$/M, output_$/M)
15
+ # Matches are done by checking if the model id STARTS WITH any key (longest first).
16
+ _PRICING_TABLE: dict[str, tuple[float, float]] = {
17
+ # ── Gemini 2.5 ──────────────────────────────────────────────────────────
18
+ "gemini-2.5-pro": (3.50, 10.50), # standard context ≤200k
19
+ "gemini-2.5-flash": (0.15, 0.60), # standard context
20
+ "gemini-2.5-flash-8b": (0.037, 0.15), # 8B lite variant
21
+ # ── Gemini 2.0 ──────────────────────────────────────────────────────────
22
+ "gemini-2.0-flash": (0.10, 0.40),
23
+ "gemini-2.0-flash-lite": (0.075, 0.30),
24
+ "gemini-2.0-pro": (3.50, 10.50),
25
+ # ── Gemini 1.5 ──────────────────────────────────────────────────────────
26
+ "gemini-1.5-flash": (0.075, 0.30),
27
+ "gemini-1.5-pro": (1.25, 5.00),
28
+ # ── Gemini experimental / preview ───────────────────────────────────────
29
+ "gemini-exp": (0.00, 0.00), # free during preview
30
+ "gemini-3": (0.30, 1.20), # approximate future pricing
31
+ }
32
+
33
+
34
+ def estimate_cost(
35
+ model: str,
36
+ *,
37
+ input_tokens: int = 0,
38
+ output_tokens: int = 0,
39
+ ) -> float | None:
40
+ """
41
+ Return estimated USD cost for a model turn, or None if pricing is unknown.
42
+
43
+ Args:
44
+ model: Model id string (e.g. "gemini-2.5-flash").
45
+ input_tokens: Prompt/input token count.
46
+ output_tokens: Candidates/output token count.
47
+ """
48
+ if not model:
49
+ return None
50
+ model_lower = model.lower().strip()
51
+ # Match longest prefix first for specificity.
52
+ for prefix in sorted(_PRICING_TABLE, key=len, reverse=True):
53
+ if model_lower.startswith(prefix):
54
+ in_rate, out_rate = _PRICING_TABLE[prefix]
55
+ return (input_tokens * in_rate + output_tokens * out_rate) / 1_000_000
56
+ return None
57
+
58
+
59
+ def format_cost(usd: float | None) -> str:
60
+ """Human-readable cost string, e.g. '$0.0012' or '$1.23'."""
61
+ if usd is None:
62
+ return ""
63
+ if usd < 0.0001:
64
+ return f"<$0.0001"
65
+ if usd < 0.01:
66
+ return f"${usd:.4f}"
67
+ return f"${usd:.3f}"
68
+
69
+
70
+ def format_tokens(n: int) -> str:
71
+ """Format a token count compactly: 1234 → '1.2k', 12345678 → '12.3M'."""
72
+ if n >= 1_000_000:
73
+ return f"{n / 1_000_000:.1f}M"
74
+ if n >= 1_000:
75
+ return f"{n / 1_000:.1f}k"
76
+ return str(n)
@@ -101,7 +101,8 @@ def format_memory_lines(cfg: GemCodeConfig) -> list[str]:
101
101
 
102
102
  def format_hooks_lines(cfg: GemCodeConfig) -> list[str]:
103
103
  env_hook = os.environ.get("GEMCODE_POST_TURN_HOOK")
104
- default_hook = cfg.project_root / ".gemcode" / "hooks" / "post_turn"
104
+ hooks_dir = cfg.project_root / ".gemcode" / "hooks"
105
+ default_hook = hooks_dir / "post_turn"
105
106
  active: str | None = env_hook
106
107
  if not active and _is_executable(default_hook):
107
108
  active = str(default_hook)
@@ -115,6 +116,31 @@ def format_hooks_lines(cfg: GemCodeConfig) -> list[str]:
115
116
  lines.append(f" active_hook: {active}")
116
117
  else:
117
118
  lines.append(" active_hook: (none — set env or chmod +x .gemcode/hooks/post_turn)")
119
+
120
+ # ── Shell lifecycle hooks (like OpenClaude PreToolUse / PostToolUse) ──────
121
+ lines.append("")
122
+ lines.append("Shell lifecycle hooks (.gemcode/hooks/):")
123
+ lines.append(" Scripts run at tool/session lifecycle points.")
124
+ lifecycle_hooks = [
125
+ ("pre_tool_use", "Run BEFORE each tool call. Non-zero exit → tool denied."),
126
+ ("post_tool_use", "Run AFTER each tool call. Informational; return ignored."),
127
+ ("session_start", "Run when a GemCode session starts."),
128
+ ("session_stop", "Run when a GemCode session ends."),
129
+ ]
130
+ for hook_name, description in lifecycle_hooks:
131
+ found = None
132
+ for ext in ("", ".sh", ".py", ".bash"):
133
+ p = hooks_dir / f"{hook_name}{ext}"
134
+ if p.is_file() and _is_executable(p):
135
+ found = p
136
+ break
137
+ status = f"✓ {found}" if found else "✗ not found (create and chmod +x to enable)"
138
+ lines.append(f" {hook_name:20s} {status}")
139
+ lines.append(f" {'':20s} {description}")
140
+ lines.append("")
141
+ lines.append(f" Hook directory: {hooks_dir}")
142
+ lines.append(" Hooks receive JSON on stdin with tool name, args, and result.")
143
+ lines.append(" Set GEMCODE_HOOK_TYPE env var is set for each hook.")
118
144
  return lines
119
145
 
120
146
 
@@ -217,4 +243,5 @@ def slash_help_lines() -> list[str]:
217
243
  " /hooks Show post-turn hook configuration",
218
244
  " /kairos How to launch the background parallel job scheduler",
219
245
  " /code [on|off] Toggle sandboxed Python executor (ADK BuiltInCodeExecutor)",
246
+ " /plan [on|off] Toggle plan mode — agent plans before executing tools",
220
247
  ]
@@ -357,6 +357,32 @@ async def process_repl_slash(
357
357
  out()
358
358
  return ReplSlashResult(skip_model_turn=True)
359
359
 
360
+ # ── /plan ─────────────────────────────────────────────────────────────────
361
+ if name == "plan":
362
+ args_s = (sc.args or "").strip().lower()
363
+ if args_s in ("on", "enable", "1", "true"):
364
+ cfg.plan_mode = True
365
+ out("Plan mode: ON")
366
+ out("The agent will now write an explicit numbered plan BEFORE executing")
367
+ out("any tools. It will pause for your confirmation before proceeding.")
368
+ out()
369
+ out("Type /plan off to disable.")
370
+ return ReplSlashResult(skip_model_turn=True, force_rebuild_runner=True)
371
+ elif args_s in ("off", "disable", "0", "false"):
372
+ cfg.plan_mode = False
373
+ out("Plan mode: OFF")
374
+ return ReplSlashResult(skip_model_turn=True, force_rebuild_runner=True)
375
+ else:
376
+ status = "ON" if getattr(cfg, "plan_mode", False) else "OFF"
377
+ out(f"Plan mode: {status}")
378
+ out()
379
+ out("When ON, the agent writes a numbered plan and waits for your")
380
+ out("confirmation before executing any file or shell operations.")
381
+ out()
382
+ out("Best for: complex multi-file refactors, migrations, risky changes.")
383
+ out("Toggle: /plan on /plan off")
384
+ return ReplSlashResult(skip_model_turn=True)
385
+
360
386
  # ── /code ─────────────────────────────────────────────────────────────────
361
387
  if name == "code":
362
388
  args_s = (sc.args or "").strip().lower()
@@ -603,19 +629,23 @@ async def process_repl_slash(
603
629
  # ── /caps ─────────────────────────────────────────────────────────────────
604
630
  if name in ("caps", "capabilities", "capability"):
605
631
  args_s = (sc.args or "").strip().lower()
606
- valid_caps = ("auto", "research", "embeddings", "computer", "all", "none", "reset")
632
+ valid_caps = ("auto", "research", "embeddings", "computer", "search", "all", "none", "reset")
607
633
  out("Active capabilities:")
634
+ out(f" web_search: {'on' if getattr(cfg, 'enable_web_search', False) else 'off'}")
608
635
  out(f" deep_research: {'on' if cfg.enable_deep_research else 'off'}")
609
636
  out(f" embeddings: {'on' if cfg.enable_embeddings else 'off'}")
610
637
  out(f" memory: {'on' if cfg.enable_memory else 'off'}")
611
638
  out(f" computer_use: {'on' if cfg.enable_computer_use else 'off'}")
612
639
  out(f" maps_grounding: {'on' if cfg.enable_maps_grounding else 'off'}")
640
+ out(f" code_executor: {'on' if getattr(cfg, 'enable_code_executor', False) else 'off'}")
641
+ out(f" plan_mode: {'on' if getattr(cfg, 'plan_mode', False) else 'off'}")
613
642
  out(f" capability_mode (auto-routing): {cfg.capability_mode}")
614
643
  out()
615
644
  if not args_s:
616
645
  out("Commands:")
617
646
  out(" /caps none — turn all off, capability_mode=auto")
618
- out(" /caps research enable_deep_research on")
647
+ out(" /caps search enable_web_search on (standalone google_search)")
648
+ out(" /caps research — enable_deep_research on (search + url_context)")
619
649
  out(" /caps embeddings — enable_embeddings on")
620
650
  out(" /caps all — all modalities on")
621
651
  out(" /caps reset — reset to startup defaults (all off, auto mode)")
@@ -626,10 +656,18 @@ async def process_repl_slash(
626
656
  cfg.enable_embeddings = False
627
657
  cfg.enable_computer_use = False
628
658
  cfg.enable_maps_grounding = False
659
+ if hasattr(cfg, "enable_web_search"):
660
+ cfg.enable_web_search = False
629
661
  cfg.capability_mode = "auto"
630
662
  out("capabilities: reset to defaults (all off, auto mode)")
631
663
  out()
632
664
  return ReplSlashResult(skip_model_turn=True, force_rebuild_runner=True)
665
+ if args_s == "search":
666
+ if hasattr(cfg, "enable_web_search"):
667
+ cfg.enable_web_search = True
668
+ out("enable_web_search: on (google_search available without full deep_research)")
669
+ out()
670
+ return ReplSlashResult(skip_model_turn=True, force_rebuild_runner=True)
633
671
  if args_s == "research":
634
672
  cfg.enable_deep_research = True
635
673
  out("enable_deep_research: on (runner rebuilding…)")
@@ -650,6 +688,8 @@ async def process_repl_slash(
650
688
  cfg.enable_deep_research = True
651
689
  cfg.enable_embeddings = True
652
690
  cfg.enable_computer_use = True
691
+ if hasattr(cfg, "enable_web_search"):
692
+ cfg.enable_web_search = True
653
693
  out("capabilities: all on (runner rebuilding…)")
654
694
  out()
655
695
  return ReplSlashResult(skip_model_turn=True, force_rebuild_runner=True)
@@ -57,6 +57,7 @@ SLASH_COMMANDS: list[tuple[str, str]] = [
57
57
  ("limits", "Show/set execution limits (max_llm_calls, context, etc.)"),
58
58
  ("kairos", "Background parallel job scheduler — how to run gemcode kairos"),
59
59
  ("code", "Toggle sandboxed Python code executor (ADK BuiltInCodeExecutor)"),
60
+ ("plan", "Toggle plan mode — agent writes explicit plan before executing tools"),
60
61
  ("tools", "List all tools and their permission categories"),
61
62
  ("config", "Show full active configuration (all fields)"),
62
63
  ("permissions", "Show current permission mode (default / strict / yes)"),
@@ -114,10 +115,12 @@ class GemCodeInputHandler:
114
115
  ansi_enabled: bool = True,
115
116
  get_model: Callable[[], str] | None = None,
116
117
  get_session_id: Callable[[], str] | None = None,
118
+ get_cfg: Callable[[], object | None] | None = None,
117
119
  ) -> None:
118
120
  self._ansi = ansi_enabled
119
121
  self._get_model = get_model or (lambda: "gemini")
120
122
  self._get_session_id = get_session_id or (lambda: "")
123
+ self._get_cfg = get_cfg or (lambda: None)
121
124
  self._session: "PromptSession | None" = None
122
125
 
123
126
  if _PT_AVAILABLE and sys.stdin.isatty() and sys.stdout.isatty():
@@ -142,15 +145,30 @@ class GemCodeInputHandler:
142
145
 
143
146
  get_model = self._get_model
144
147
  get_session_id = self._get_session_id
148
+ get_cfg = self._get_cfg
145
149
 
146
150
  def bottom_toolbar() -> HTML:
147
151
  model = get_model() or "gemini"
148
152
  sid = get_session_id()
149
153
  sid_short = sid[:8] if len(sid) >= 8 else sid
154
+ cfg = get_cfg()
155
+ extras = ""
156
+ if cfg is not None:
157
+ flags = []
158
+ if getattr(cfg, "plan_mode", False):
159
+ flags.append("PLAN")
160
+ if getattr(cfg, "enable_code_executor", False):
161
+ flags.append("CODE")
162
+ if getattr(cfg, "enable_deep_research", False):
163
+ flags.append("RESEARCH")
164
+ if getattr(cfg, "enable_computer_use", False):
165
+ flags.append("BROWSER")
166
+ if flags:
167
+ extras = " · " + " ".join(f"[{f}]" for f in flags)
150
168
  return HTML(
151
169
  f'<style bg="#0d1f2d" fg="#5fafd7"><b> ◆ {model}</b></style>'
152
170
  f'<style bg="#0d1f2d" fg="#3d6080"> · session {sid_short}'
153
- f' · / for commands</style>'
171
+ f'{extras} · / for commands</style>'
154
172
  )
155
173
 
156
174
  kb = KeyBindings()
@@ -204,6 +204,13 @@ async def run_gemcode_scrollback_tui(
204
204
  load_cli_environment()
205
205
  os.environ["GEMCODE_TUI_ACTIVE"] = "1"
206
206
 
207
+ # ── Session-start hook ──────────────────────────────────────────────────
208
+ try:
209
+ from gemcode.hooks import run_session_start_hook
210
+ run_session_start_hook(cfg.project_root, model=getattr(cfg, "model", "") or "")
211
+ except Exception:
212
+ pass
213
+
207
214
  ansi = _Ansi(
208
215
  enabled=(
209
216
  sys.stdout.isatty()
@@ -234,6 +241,7 @@ async def run_gemcode_scrollback_tui(
234
241
  ansi_enabled=ansi.enabled,
235
242
  get_model=lambda: getattr(cfg, "model", "gemini") or "gemini",
236
243
  get_session_id=lambda: _current_session_id_holder[0],
244
+ get_cfg=lambda: cfg,
237
245
  )
238
246
 
239
247
  async def typewrite(text: str) -> None:
@@ -437,10 +445,20 @@ async def run_gemcode_scrollback_tui(
437
445
  prompt = await input_handler.prompt_async()
438
446
  except EOFError:
439
447
  print("")
448
+ try:
449
+ from gemcode.hooks import run_session_stop_hook
450
+ run_session_stop_hook(cfg.project_root, model=getattr(cfg, "model", "") or "")
451
+ except Exception:
452
+ pass
440
453
  return
441
454
  if not prompt:
442
455
  continue
443
456
  if prompt in (":q", "quit", "exit", "/exit"):
457
+ try:
458
+ from gemcode.hooks import run_session_stop_hook
459
+ run_session_stop_hook(cfg.project_root, model=getattr(cfg, "model", "") or "")
460
+ except Exception:
461
+ pass
444
462
  return
445
463
 
446
464
  old_model = getattr(cfg, "model", "")
@@ -707,7 +725,32 @@ async def run_gemcode_scrollback_tui(
707
725
  else current_session_id
708
726
  )
709
727
  model = getattr(cfg, "model", "") or ""
710
- print(f"{ansi.dim} · {model} · session {sid}{ansi.reset}")
728
+ # ── Token / cost stats from last turn ──────────────────────────────────
729
+ stats = getattr(cfg, "_last_turn_stats", None)
730
+ token_part = ""
731
+ if stats:
732
+ try:
733
+ from gemcode.pricing import format_cost, format_tokens
734
+ in_tok = stats.get("in", 0) or 0
735
+ out_tok = stats.get("out", 0) or 0
736
+ think_tok = stats.get("think", 0) or 0
737
+ session_total = stats.get("session_total", 0) or 0
738
+ turn_cost = stats.get("turn_cost")
739
+ session_cost = stats.get("session_cost")
740
+ parts_t: list[str] = []
741
+ parts_t.append(f"↑{format_tokens(in_tok)} ↓{format_tokens(out_tok)}")
742
+ if think_tok:
743
+ parts_t.append(f"✦{format_tokens(think_tok)}")
744
+ if turn_cost is not None:
745
+ parts_t.append(format_cost(turn_cost))
746
+ if session_total:
747
+ parts_t.append(f"session {format_tokens(session_total)}")
748
+ if session_cost and session_cost > 0.0001:
749
+ parts_t.append(f"total {format_cost(session_cost)}")
750
+ token_part = " · " + " · ".join(parts_t)
751
+ except Exception:
752
+ pass
753
+ print(f"{ansi.dim} · {model} · session {sid}{token_part}{ansi.reset}")
711
754
  if os.environ.get("GEMCODE_TUI_TURN_RULE", "1").lower() in (
712
755
  "1",
713
756
  "true",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gemcode
3
- Version: 0.3.29
3
+ Version: 0.3.30
4
4
  Summary: Local-first coding agent on Google Gemini + ADK
5
5
  Author: GemCode Contributors
6
6
  License: Apache License
@@ -16,6 +16,7 @@ src/gemcode/context_budget.py
16
16
  src/gemcode/context_warning.py
17
17
  src/gemcode/credentials.py
18
18
  src/gemcode/hitl_session.py
19
+ src/gemcode/hooks.py
19
20
  src/gemcode/interactions.py
20
21
  src/gemcode/invoke.py
21
22
  src/gemcode/kairos_daemon.py
@@ -28,6 +29,7 @@ src/gemcode/model_errors.py
28
29
  src/gemcode/model_routing.py
29
30
  src/gemcode/paths.py
30
31
  src/gemcode/permissions.py
32
+ src/gemcode/pricing.py
31
33
  src/gemcode/prompt_suggestions.py
32
34
  src/gemcode/refine.py
33
35
  src/gemcode/repl_commands.py
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes